core-agent-ide/codex-rs/network-proxy/src/runtime.rs
viyatb-oai 25fa974166
fix: support managed network allowlist controls (#12752)
## Summary
- treat `requirements.toml` `allowed_domains` and `denied_domains` as
managed network baselines for the proxy
- in restricted modes by default, build the effective runtime policy
from the managed baseline plus user-configured allowlist and denylist
entries, so common hosts can be pre-approved without blocking later user
expansion
- add `experimental_network.managed_allowed_domains_only = true` to pin
the effective allowlist to managed entries, ignore user allowlist
additions, and hard-deny non-managed domains without prompting
- apply `managed_allowed_domains_only` anywhere managed network
enforcement is active, including full access, while continuing to
respect denied domains from all sources
- add regression coverage for merged-baseline behavior, managed-only
behavior, and full-access managed-only enforcement

## Behavior
Assuming `requirements.toml` defines both
`experimental_network.allowed_domains` and
`experimental_network.denied_domains`.

### Default mode
- By default, the effective allowlist is
`experimental_network.allowed_domains` plus user or persisted allowlist
additions.
- By default, the effective denylist is
`experimental_network.denied_domains` plus user or persisted denylist
additions.
- Allowlist misses can go through the network approval flow.
- Explicit denylist hits and local or private-network blocks are still
hard-denied.
- When `experimental_network.managed_allowed_domains_only = true`, only
managed `allowed_domains` are respected, user allowlist additions are
ignored, and non-managed domains are hard-denied without prompting.
- Denied domains continue to be respected from all sources.

### Full access
- With managed requirements present, the effective allowlist is pinned
to `experimental_network.allowed_domains`.
- With managed requirements present, the effective denylist is pinned to
`experimental_network.denied_domains`.
- There is no allowlist-miss approval path in full access.
- Explicit denylist hits are hard-denied.
- `experimental_network.managed_allowed_domains_only = true` now also
applies in full access, so managed-only behavior remains in effect
anywhere managed network enforcement is active.
2026-03-06 17:52:54 -08:00

1671 lines
57 KiB
Rust

use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::ValidatedUnixSocketPath;
use crate::mitm::MitmState;
use crate::policy::Host;
use crate::policy::is_loopback_host;
use crate::policy::is_non_public_ip;
use crate::policy::normalize_host;
use crate::reasons::REASON_DENIED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
use crate::state::NetworkProxyConstraintError;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use crate::state::validate_policy_against_constraints;
use anyhow::Context;
use anyhow::Result;
use async_trait::async_trait;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobSet;
use serde::Serialize;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::future::Future;
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::net::lookup_host;
use tokio::sync::RwLock;
use tokio::time::timeout;
use tracing::debug;
use tracing::info;
use tracing::warn;
const MAX_BLOCKED_EVENTS: usize = 200;
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,
pub user_account_id: Option<String>,
pub auth_mode: Option<String>,
pub originator: Option<String>,
pub user_email: Option<String>,
pub terminal_type: Option<String>,
pub model: Option<String>,
pub slug: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockReason {
Denied,
NotAllowed,
NotAllowedLocal,
}
impl HostBlockReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::Denied => REASON_DENIED,
Self::NotAllowed => REASON_NOT_ALLOWED,
Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL,
}
}
}
impl std::fmt::Display for HostBlockReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockDecision {
Allowed,
Blocked(HostBlockReason),
}
#[derive(Clone, Debug, Serialize)]
pub struct BlockedRequest {
pub host: String,
pub reason: String,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub timestamp: i64,
}
pub struct BlockedRequestArgs {
pub host: String,
pub reason: String,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub decision: Option<String>,
pub source: Option<String>,
pub port: Option<u16>,
}
impl BlockedRequest {
pub fn new(args: BlockedRequestArgs) -> Self {
let BlockedRequestArgs {
host,
reason,
client,
method,
mode,
protocol,
decision,
source,
port,
} = args;
Self {
host,
reason,
client,
method,
mode,
protocol,
decision,
source,
port,
timestamp: unix_timestamp(),
}
}
}
fn blocked_request_violation_log_line(entry: &BlockedRequest) -> String {
match serde_json::to_string(entry) {
Ok(json) => format!("{NETWORK_POLICY_VIOLATION_PREFIX} {json}"),
Err(err) => {
debug!("failed to serialize blocked request for violation log: {err}");
format!(
"{NETWORK_POLICY_VIOLATION_PREFIX} host={} reason={}",
entry.host, entry.reason
)
}
}
}
#[derive(Clone)]
pub struct ConfigState {
pub config: NetworkProxyConfig,
pub allow_set: GlobSet,
pub deny_set: GlobSet,
pub mitm: Option<Arc<MitmState>>,
pub constraints: NetworkProxyConstraints,
pub blocked: VecDeque<BlockedRequest>,
pub blocked_total: u64,
}
#[async_trait]
pub trait ConfigReloader: Send + Sync {
/// Human-readable description of where config is loaded from, for logs.
fn source_label(&self) -> String;
/// Return a freshly loaded state if a reload is needed; otherwise, return `None`.
async fn maybe_reload(&self) -> Result<Option<ConfigState>>;
/// Force a reload, regardless of whether a change was detected.
async fn reload_now(&self) -> Result<ConfigState>;
}
#[async_trait]
pub trait BlockedRequestObserver: Send + Sync + 'static {
async fn on_blocked_request(&self, request: BlockedRequest);
}
#[async_trait]
impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
async fn on_blocked_request(&self, request: BlockedRequest) {
(**self).on_blocked_request(request).await
}
}
#[async_trait]
impl<F, Fut> BlockedRequestObserver for F
where
F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send,
{
async fn on_blocked_request(&self, request: BlockedRequest) {
(self)(request).await
}
}
pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
audit_metadata: NetworkProxyAuditMetadata,
}
impl std::fmt::Debug for NetworkProxyState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
// and may contain sensitive paths.
f.debug_struct("NetworkProxyState").finish_non_exhaustive()
}
}
impl Clone for NetworkProxyState {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.clone(),
audit_metadata: self.audit_metadata.clone(),
}
}
}
impl NetworkProxyState {
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_audit_metadata(
state,
reloader,
NetworkProxyAuditMetadata::default(),
)
}
pub fn with_reloader_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
NetworkProxyAuditMetadata::default(),
blocked_request_observer,
)
}
pub fn with_reloader_and_audit_metadata(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
audit_metadata,
None,
)
}
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
audit_metadata,
}
}
pub async fn set_blocked_request_observer(
&self,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) {
let mut observer = self.blocked_request_observer.write().await;
*observer = blocked_request_observer;
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
// `config.toml` (including Codex-managed writes) take effect without a restart.
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.clone())
}
pub async fn current_patterns(&self) -> Result<(Vec<String>, Vec<String>)> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok((
guard.config.network.allowed_domains.clone(),
guard.config.network.denied_domains.clone(),
))
}
pub async fn enabled(&self) -> Result<bool> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.network.enabled)
}
pub async fn force_reload(&self) -> Result<()> {
let previous_cfg = {
let guard = self.state.read().await;
guard.config.clone()
};
match self.reloader.reload_now().await {
Ok(mut new_state) => {
// Policy changes are operationally sensitive; logging diffs makes changes traceable
// without needing to dump full config blobs (which can include unrelated settings).
log_policy_changes(&previous_cfg, &new_state.config);
{
let mut guard = self.state.write().await;
new_state.blocked = guard.blocked.clone();
*guard = new_state;
}
let source = self.reloader.source_label();
info!("reloaded config from {source}");
Ok(())
}
Err(err) => {
let source = self.reloader.source_label();
warn!("failed to reload config from {source}: {err}; keeping previous config");
Err(err)
}
}
}
pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
self.reload_if_needed().await?;
let host = match Host::parse(host) {
Ok(host) => host,
Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
};
let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = {
let guard = self.state.read().await;
(
guard.deny_set.clone(),
guard.allow_set.clone(),
guard.config.network.allow_local_binding,
guard.config.network.allowed_domains.is_empty(),
guard.config.network.allowed_domains.clone(),
)
};
let host_str = host.as_str();
// Decision order matters:
// 1) explicit deny always wins
// 2) local/private networking is opt-in (defense-in-depth)
// 3) allowlist is enforced when configured
if deny_set.is_match(host_str) {
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
}
let is_allowlisted = allow_set.is_match(host_str);
if !allow_local_binding {
// If the intent is "prevent access to local/internal networks", we must not rely solely
// on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or
// public suffix services that map hostnames onto private IPs.
//
// We therefore do a best-effort DNS + IP classification check before allowing the
// request. Explicit local/loopback literals are allowed only when explicitly
// allowlisted; hostnames that resolve to local/private IPs are blocked even if
// allowlisted.
let local_literal = {
let host_no_scope = host_str
.split_once('%')
.map(|(ip, _)| ip)
.unwrap_or(host_str);
if is_loopback_host(&host) {
true
} else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
is_non_public_ip(ip)
} else {
false
}
};
if local_literal {
if !is_explicit_local_allowlisted(&allowed_domains, &host) {
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
}
} else if host_resolves_to_non_public_ip(host_str, port).await {
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
}
}
if allowed_domains_empty || !is_allowlisted {
Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
} else {
Ok(HostBlockDecision::Allowed)
}
}
pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
self.reload_if_needed().await?;
let blocked_for_observer = entry.clone();
let blocked_request_observer = self.blocked_request_observer.read().await.clone();
let violation_line = blocked_request_violation_log_line(&entry);
let mut guard = self.state.write().await;
let host = entry.host.clone();
let reason = entry.reason.clone();
let decision = entry.decision.clone();
let source = entry.source.clone();
let protocol = entry.protocol.clone();
let port = entry.port;
guard.blocked.push_back(entry);
guard.blocked_total = guard.blocked_total.saturating_add(1);
let total = guard.blocked_total;
while guard.blocked.len() > MAX_BLOCKED_EVENTS {
guard.blocked.pop_front();
}
debug!(
"recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})",
total,
host,
reason,
decision,
source,
protocol,
port,
guard.blocked.len()
);
debug!("{violation_line}");
drop(guard);
if let Some(observer) = blocked_request_observer {
observer.on_blocked_request(blocked_for_observer).await;
}
Ok(())
}
/// Returns a snapshot of buffered blocked-request entries without consuming
/// them.
pub async fn blocked_snapshot(&self) -> Result<Vec<BlockedRequest>> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.blocked.iter().cloned().collect())
}
/// Drain and return the buffered blocked-request entries in FIFO order.
pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
self.reload_if_needed().await?;
let blocked = {
let mut guard = self.state.write().await;
std::mem::take(&mut guard.blocked)
};
Ok(blocked.into_iter().collect())
}
pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
self.reload_if_needed().await?;
if !unix_socket_permissions_supported() {
return Ok(false);
}
// We only support absolute unix socket paths (a relative path would be ambiguous with
// respect to the proxy process's CWD and can lead to confusing allowlist behavior).
let requested_path = Path::new(path);
if !requested_path.is_absolute() {
return Ok(false);
}
let guard = self.state.read().await;
if guard.config.network.dangerously_allow_all_unix_sockets {
return Ok(true);
}
// Normalize the path while keeping the absolute-path requirement explicit.
let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) {
Ok(path) => path,
Err(_) => return Ok(false),
};
let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
for allowed in &guard.config.network.allow_unix_sockets {
let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
Ok(ValidatedUnixSocketPath::Native(path)) => path,
Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
Err(err) => {
warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}");
continue;
}
};
if allowed_path.as_path() == requested_abs.as_path() {
return Ok(true);
}
// Best-effort canonicalization to reduce surprises with symlinks.
// If canonicalization fails (e.g., socket not created yet), fall back to raw comparison.
let Some(requested_canonical) = &requested_canonical else {
continue;
};
if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path())
&& &allowed_canonical == requested_canonical
{
return Ok(true);
}
}
Ok(false)
}
pub async fn method_allowed(&self, method: &str) -> Result<bool> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.network.mode.allows_method(method))
}
pub async fn allow_upstream_proxy(&self) -> Result<bool> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.network.allow_upstream_proxy)
}
pub async fn network_mode(&self) -> Result<NetworkMode> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.network.mode)
}
pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> {
loop {
self.reload_if_needed().await?;
let (candidate, constraints) = {
let guard = self.state.read().await;
let mut candidate = guard.config.clone();
candidate.network.mode = mode;
(candidate, guard.constraints.clone())
};
validate_policy_against_constraints(&candidate, &constraints)
.map_err(NetworkProxyConstraintError::into_anyhow)
.context("network.mode constrained by managed config")?;
let mut guard = self.state.write().await;
if guard.constraints != constraints {
drop(guard);
continue;
}
guard.config.network.mode = mode;
info!("updated network mode to {mode:?}");
return Ok(());
}
}
pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.mitm.clone())
}
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
self.update_domain_list(host, DomainListKind::Allow).await
}
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
self.update_domain_list(host, DomainListKind::Deny).await
}
async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
let host = Host::parse(host).context("invalid network host")?;
let normalized_host = host.as_str().to_string();
let list_name = target.list_name();
let constraint_field = target.constraint_field();
loop {
self.reload_if_needed().await?;
let (previous_cfg, constraints, blocked, blocked_total) = {
let guard = self.state.read().await;
(
guard.config.clone(),
guard.constraints.clone(),
guard.blocked.clone(),
guard.blocked_total,
)
};
let mut candidate = previous_cfg.clone();
let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target);
let target_contains = target_entries
.iter()
.any(|entry| normalize_host(entry) == normalized_host);
let opposite_contains = opposite_entries
.iter()
.any(|entry| normalize_host(entry) == normalized_host);
if target_contains && !opposite_contains {
return Ok(());
}
target_entries.retain(|entry| normalize_host(entry) != normalized_host);
target_entries.push(normalized_host.clone());
opposite_entries.retain(|entry| normalize_host(entry) != normalized_host);
validate_policy_against_constraints(&candidate, &constraints)
.map_err(NetworkProxyConstraintError::into_anyhow)
.with_context(|| format!("{constraint_field} constrained by managed config"))?;
let mut new_state = build_config_state(candidate.clone(), constraints.clone())
.with_context(|| format!("failed to compile updated network {list_name}"))?;
new_state.blocked = blocked;
new_state.blocked_total = blocked_total;
let mut guard = self.state.write().await;
if guard.constraints != constraints || guard.config != previous_cfg {
drop(guard);
continue;
}
log_policy_changes(&guard.config, &candidate);
*guard = new_state;
info!("updated network {list_name} with {normalized_host}");
return Ok(());
}
}
async fn reload_if_needed(&self) -> Result<()> {
match self.reloader.maybe_reload().await? {
None => Ok(()),
Some(mut new_state) => {
let (previous_cfg, blocked, blocked_total) = {
let guard = self.state.read().await;
(
guard.config.clone(),
guard.blocked.clone(),
guard.blocked_total,
)
};
log_policy_changes(&previous_cfg, &new_state.config);
new_state.blocked = blocked;
new_state.blocked_total = blocked_total;
{
let mut guard = self.state.write().await;
*guard = new_state;
}
let source = self.reloader.source_label();
info!("reloaded config from {source}");
Ok(())
}
}
}
}
#[derive(Clone, Copy)]
enum DomainListKind {
Allow,
Deny,
}
impl DomainListKind {
fn list_name(self) -> &'static str {
match self {
Self::Allow => "allowlist",
Self::Deny => "denylist",
}
}
fn constraint_field(self) -> &'static str {
match self {
Self::Allow => "network.allowed_domains",
Self::Deny => "network.denied_domains",
}
}
}
impl NetworkProxyConfig {
fn split_domain_lists_mut(
&mut self,
target: DomainListKind,
) -> (&mut Vec<String>, &mut Vec<String>) {
match target {
DomainListKind::Allow => (
&mut self.network.allowed_domains,
&mut self.network.denied_domains,
),
DomainListKind::Deny => (
&mut self.network.denied_domains,
&mut self.network.allowed_domains,
),
}
}
}
pub(crate) fn unix_socket_permissions_supported() -> bool {
cfg!(target_os = "macos")
}
async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool {
if let Ok(ip) = host.parse::<IpAddr>() {
return is_non_public_ip(ip);
}
// If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the
// subsequent connect attempt will fail anyway, and blocking on transient resolver issues would
// make the proxy fragile. The allowlist/denylist remains the primary control plane.
let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await {
Ok(Ok(addrs)) => addrs,
Ok(Err(_)) | Err(_) => return false,
};
for addr in addrs {
if is_non_public_ip(addr.ip()) {
return true;
}
}
false
}
fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
log_domain_list_changes(
"allowlist",
&previous.network.allowed_domains,
&next.network.allowed_domains,
);
log_domain_list_changes(
"denylist",
&previous.network.denied_domains,
&next.network.denied_domains,
);
}
fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
let previous_set: HashSet<String> = previous
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let next_set: HashSet<String> = next
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let added = next_set
.difference(&previous_set)
.cloned()
.collect::<HashSet<_>>();
let removed = previous_set
.difference(&next_set)
.cloned()
.collect::<HashSet<_>>();
let mut seen_next = HashSet::new();
for entry in next {
let key = entry.to_ascii_lowercase();
if seen_next.insert(key.clone()) && added.contains(&key) {
info!("config entry added to {list_name}: {entry}");
}
}
let mut seen_previous = HashSet::new();
for entry in previous {
let key = entry.to_ascii_lowercase();
if seen_previous.insert(key.clone()) && removed.contains(&key) {
info!("config entry removed from {list_name}: {entry}");
}
}
}
fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool {
let normalized_host = host.as_str();
allowed_domains.iter().any(|pattern| {
let pattern = pattern.trim();
if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") {
return false;
}
if pattern.contains('*') || pattern.contains('?') {
return false;
}
normalize_host(pattern) == normalized_host
})
}
fn unix_timestamp() -> i64 {
OffsetDateTime::now_utc().unix_timestamp()
}
#[cfg(test)]
pub(crate) fn network_proxy_state_for_policy(
mut network: crate::config::NetworkProxySettings,
) -> NetworkProxyState {
network.enabled = true;
network.mode = NetworkMode::Full;
let config = NetworkProxyConfig { network };
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
}
#[cfg(test)]
struct NoopReloader;
#[cfg(test)]
#[async_trait]
impl ConfigReloader for NoopReloader {
fn source_label(&self) -> String {
"test config state".to_string()
}
async fn maybe_reload(&self) -> Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> Result<ConfigState> {
Err(anyhow::anyhow!("force reload is not supported in tests"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkProxySettings;
use crate::policy::compile_globset;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use crate::state::validate_policy_against_constraints;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn host_blocked_denied_wins_over_allowed() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
denied_domains: vec!["example.com".to_string()],
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("example.com", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::Denied)
);
}
#[tokio::test]
async fn host_blocked_requires_allowlist_match() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("example.com", 80).await.unwrap(),
HostBlockDecision::Allowed
);
assert_eq!(
// Use a public IP literal to avoid relying on ambient DNS behavior (some networks
// resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`).
state.host_blocked("8.8.8.8", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
);
}
#[tokio::test]
async fn add_allowed_domain_removes_matching_deny_entry() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
denied_domains: vec!["example.com".to_string()],
..NetworkProxySettings::default()
});
state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
let (allowed, denied) = state.current_patterns().await.unwrap();
assert_eq!(allowed, vec!["example.com".to_string()]);
assert!(denied.is_empty());
assert_eq!(
state.host_blocked("example.com", 80).await.unwrap(),
HostBlockDecision::Allowed
);
}
#[tokio::test]
async fn add_denied_domain_removes_matching_allow_entry() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
..NetworkProxySettings::default()
});
state.add_denied_domain("EXAMPLE.COM").await.unwrap();
let (allowed, denied) = state.current_patterns().await.unwrap();
assert!(allowed.is_empty());
assert_eq!(denied, vec!["example.com".to_string()]);
assert_eq!(
state.host_blocked("example.com", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::Denied)
);
}
#[tokio::test]
async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["managed.example.com".to_string()],
..NetworkProxySettings::default()
},
};
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
allowlist_expansion_enabled: Some(true),
..NetworkProxyConstraints::default()
};
let state = NetworkProxyState::with_reloader(
build_config_state(config, constraints).unwrap(),
Arc::new(NoopReloader),
);
state.add_allowed_domain("user.example.com").await.unwrap();
let (allowed, denied) = state.current_patterns().await.unwrap();
assert_eq!(
allowed,
vec![
"managed.example.com".to_string(),
"user.example.com".to_string()
]
);
assert!(denied.is_empty());
}
#[tokio::test]
async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["managed.example.com".to_string()],
..NetworkProxySettings::default()
},
};
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
allowlist_expansion_enabled: Some(false),
..NetworkProxyConstraints::default()
};
let state = NetworkProxyState::with_reloader(
build_config_state(config, constraints).unwrap(),
Arc::new(NoopReloader),
);
let err = state
.add_allowed_domain("user.example.com")
.await
.expect_err("managed baseline should reject allowlist expansion");
assert!(
format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
"unexpected error: {err:#}"
);
}
#[tokio::test]
async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
denied_domains: vec!["managed.example.com".to_string()],
..NetworkProxySettings::default()
},
};
let constraints = NetworkProxyConstraints {
denied_domains: Some(vec!["managed.example.com".to_string()]),
denylist_expansion_enabled: Some(false),
..NetworkProxyConstraints::default()
};
let state = NetworkProxyState::with_reloader(
build_config_state(config, constraints).unwrap(),
Arc::new(NoopReloader),
);
let err = state
.add_denied_domain("user.example.com")
.await
.expect_err("managed baseline should reject denylist expansion");
assert!(
format!("{err:#}").contains("network.denied_domains constrained by managed config"),
"unexpected error: {err:#}"
);
}
#[tokio::test]
async fn blocked_snapshot_does_not_consume_entries() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: "google.com".to_string(),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
}))
.await
.expect("entry should be recorded");
let snapshot = state
.blocked_snapshot()
.await
.expect("snapshot should succeed");
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot[0].host, "google.com");
assert_eq!(snapshot[0].decision.as_deref(), Some("ask"));
let drained = state
.drain_blocked()
.await
.expect("drain should include snapshot entry");
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].host, snapshot[0].host);
assert_eq!(drained[0].reason, snapshot[0].reason);
assert_eq!(drained[0].decision, snapshot[0].decision);
assert_eq!(drained[0].source, snapshot[0].source);
assert_eq!(drained[0].port, snapshot[0].port);
}
#[tokio::test]
async fn drain_blocked_returns_buffered_window() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
for idx in 0..(MAX_BLOCKED_EVENTS + 5) {
state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: format!("example{idx}.com"),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
}))
.await
.expect("entry should be recorded");
}
let blocked = state.drain_blocked().await.expect("drain should succeed");
assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS);
assert_eq!(blocked[0].host, "example5.com");
}
#[test]
fn blocked_request_violation_log_line_serializes_payload() {
let entry = BlockedRequest {
host: "google.com".to_string(),
reason: "not_allowed".to_string(),
client: Some("127.0.0.1".to_string()),
method: Some("GET".to_string()),
mode: Some(NetworkMode::Full),
protocol: "http".to_string(),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
timestamp: 1_735_689_600,
};
assert_eq!(
blocked_request_violation_log_line(&entry),
r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"#
);
}
#[tokio::test]
async fn host_blocked_subdomain_wildcards_exclude_apex() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["*.openai.com".to_string()],
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("api.openai.com", 80).await.unwrap(),
HostBlockDecision::Allowed
);
assert_eq!(
state.host_blocked("openai.com", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
);
}
#[tokio::test]
async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("127.0.0.1", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
);
assert_eq!(
state.host_blocked("localhost", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
);
}
#[tokio::test]
async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["localhost".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("localhost", 80).await.unwrap(),
HostBlockDecision::Allowed
);
}
#[tokio::test]
async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["10.0.0.1".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("10.0.0.1", 80).await.unwrap(),
HostBlockDecision::Allowed
);
}
#[tokio::test]
async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
);
}
#[tokio::test]
async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["fe80::1%lo0".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
HostBlockDecision::Allowed
);
}
#[tokio::test]
async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("10.0.0.1", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
);
}
#[tokio::test]
async fn host_blocked_rejects_loopback_when_allowlist_empty() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec![],
allow_local_binding: false,
..NetworkProxySettings::default()
});
assert_eq!(
state.host_blocked("127.0.0.1", 80).await.unwrap(),
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
);
}
#[test]
fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["example.com".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["example.com".to_string()]),
allowlist_expansion_enabled: Some(true),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
}
#[test]
fn validate_policy_against_constraints_disallows_widening_mode() {
let constraints = NetworkProxyConstraints {
mode: Some(NetworkMode::Limited),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["api.example.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
}
#[test]
fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() {
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["**.example.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() {
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["*".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["api.example.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist()
{
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["[*]".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["api.example.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist()
{
let constraints = NetworkProxyConstraints {
allowed_domains: Some(vec!["**.[*]".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["api.example.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_requires_managed_denied_domains_entries() {
let constraints = NetworkProxyConstraints {
denied_domains: Some(vec!["evil.com".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
denied_domains: vec![],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
let constraints = NetworkProxyConstraints {
denied_domains: Some(vec!["evil.com".to_string()]),
denylist_expansion_enabled: Some(false),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()],
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
let constraints = NetworkProxyConstraints {
enabled: Some(false),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() {
let constraints = NetworkProxyConstraints {
allow_local_binding: Some(false),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allow_local_binding: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in()
{
let constraints = NetworkProxyConstraints {
dangerously_allow_all_unix_sockets: Some(false),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed()
{
let constraints = NetworkProxyConstraints {
allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() {
let constraints = NetworkProxyConstraints {
dangerously_allow_all_unix_sockets: Some(true),
..NetworkProxyConstraints::default()
};
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
}
#[test]
fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() {
let constraints = NetworkProxyConstraints::default();
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
}
#[test]
fn compile_globset_is_case_insensitive() {
let patterns = vec!["ExAmPle.CoM".to_string()];
let set = compile_globset(&patterns).unwrap();
assert!(set.is_match("example.com"));
assert!(set.is_match("EXAMPLE.COM"));
}
#[test]
fn compile_globset_excludes_apex_for_subdomain_patterns() {
let patterns = vec!["*.openai.com".to_string()];
let set = compile_globset(&patterns).unwrap();
assert!(set.is_match("api.openai.com"));
assert!(!set.is_match("openai.com"));
assert!(!set.is_match("evilopenai.com"));
}
#[test]
fn compile_globset_includes_apex_for_double_wildcard_patterns() {
let patterns = vec!["**.openai.com".to_string()];
let set = compile_globset(&patterns).unwrap();
assert!(set.is_match("openai.com"));
assert!(set.is_match("api.openai.com"));
assert!(!set.is_match("evilopenai.com"));
}
#[test]
fn compile_globset_rejects_global_wildcard() {
let patterns = vec!["*".to_string()];
assert!(compile_globset(&patterns).is_err());
}
#[test]
fn compile_globset_rejects_bracketed_global_wildcard() {
let patterns = vec!["[*]".to_string()];
assert!(compile_globset(&patterns).is_err());
}
#[test]
fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
let patterns = vec!["**.[*]".to_string()];
assert!(compile_globset(&patterns).is_err());
}
#[test]
fn compile_globset_dedupes_patterns_without_changing_behavior() {
let patterns = vec!["example.com".to_string(), "example.com".to_string()];
let set = compile_globset(&patterns).unwrap();
assert!(set.is_match("example.com"));
assert!(set.is_match("EXAMPLE.COM"));
assert!(!set.is_match("not-example.com"));
}
#[test]
fn compile_globset_rejects_invalid_patterns() {
let patterns = vec!["[".to_string()];
assert!(compile_globset(&patterns).is_err());
}
#[test]
fn build_config_state_rejects_global_wildcard_allowed_domains() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["*".to_string()],
..NetworkProxySettings::default()
},
};
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
}
#[test]
fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["[*]".to_string()],
..NetworkProxySettings::default()
},
};
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
}
#[test]
fn build_config_state_rejects_global_wildcard_denied_domains() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["example.com".to_string()],
denied_domains: vec!["*".to_string()],
..NetworkProxySettings::default()
},
};
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
}
#[test]
fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() {
let config = NetworkProxyConfig {
network: NetworkProxySettings {
enabled: true,
allowed_domains: vec!["example.com".to_string()],
denied_domains: vec!["[*]".to_string()],
..NetworkProxySettings::default()
},
};
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn unix_socket_allowlist_is_respected_on_macos() {
let socket_path = "/tmp/example.sock".to_string();
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_unix_sockets: vec![socket_path.clone()],
..NetworkProxySettings::default()
});
assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
assert!(
!state
.is_unix_socket_allowed("/tmp/not-allowed.sock")
.await
.unwrap()
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn unix_socket_allowlist_resolves_symlinks() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let dir = temp_dir.path();
let real = dir.join("real.sock");
let link = dir.join("link.sock");
// The allowlist mechanism is path-based; for test purposes we don't need an actual unix
// domain socket. Any filesystem entry works for canonicalization.
std::fs::write(&real, b"not a socket").unwrap();
symlink(&real, &link).unwrap();
let real_s = real.to_str().unwrap().to_string();
let link_s = link.to_str().unwrap().to_string();
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_unix_sockets: vec![real_s],
..NetworkProxySettings::default()
});
assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn unix_socket_allow_all_flag_bypasses_allowlist() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
});
assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap());
}
#[cfg(not(target_os = "macos"))]
#[tokio::test]
async fn unix_socket_allowlist_is_rejected_on_non_macos() {
let socket_path = "/tmp/example.sock".to_string();
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_unix_sockets: vec![socket_path.clone()],
dangerously_allow_all_unix_sockets: true,
..NetworkProxySettings::default()
});
assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
}
}