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.
This commit is contained in:
viyatb-oai
2026-03-06 17:52:54 -08:00
committed by GitHub
parent 5deaf9409b
commit 25fa974166
9 changed files with 520 additions and 46 deletions

View File

@@ -60,7 +60,7 @@ impl Default for NetworkProxySettings {
allowed_domains: Vec::new(),
denied_domains: Vec::new(),
allow_unix_sockets: Vec::new(),
allow_local_binding: true,
allow_local_binding: false,
mitm: false,
}
}
@@ -374,7 +374,7 @@ mod tests {
allowed_domains: Vec::new(),
denied_domains: Vec::new(),
allow_unix_sockets: Vec::new(),
allow_local_binding: true,
allow_local_binding: false,
mitm: false,
}
);

View File

@@ -894,6 +894,98 @@ mod tests {
);
}
#[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());
@@ -1117,6 +1209,25 @@ mod tests {
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 {
@@ -1245,6 +1356,25 @@ mod tests {
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 {

View File

@@ -24,7 +24,9 @@ pub struct NetworkProxyConstraints {
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub allowlist_expansion_enabled: Option<bool>,
pub denied_domains: Option<Vec<String>>,
pub denylist_expansion_enabled: Option<bool>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
}
@@ -207,31 +209,82 @@ pub fn validate_policy_against_constraints(
if let Some(allowed_domains) = &constraints.allowed_domains {
validate_domain_patterns("network.allowed_domains", allowed_domains)?;
let managed_patterns: Vec<DomainPattern> = allowed_domains
.iter()
.map(|entry| DomainPattern::parse_for_constraints(entry))
.collect();
validate(config.network.allowed_domains.clone(), move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
if !managed_patterns
match constraints.allowlist_expansion_enabled {
Some(true) => {
let required_set: HashSet<String> = allowed_domains
.iter()
.any(|managed| managed.allows(&candidate_pattern))
{
invalid.push(entry.clone());
}
.map(|entry| entry.to_ascii_lowercase())
.collect();
validate(config.network.allowed_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| !candidate_set.contains(*entry))
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
"missing managed allowed_domains entries",
format!("{missing:?}"),
))
}
})?;
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{invalid:?}"),
"subset of managed allowed_domains",
))
Some(false) => {
let required_set: HashSet<String> = allowed_domains
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
validate(config.network.allowed_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
if candidate_set == required_set {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{candidate:?}"),
"must match managed allowed_domains",
))
}
})?;
}
})?;
None => {
let managed_patterns: Vec<DomainPattern> = allowed_domains
.iter()
.map(|entry| DomainPattern::parse_for_constraints(entry))
.collect();
validate(config.network.allowed_domains.clone(), move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
if !managed_patterns
.iter()
.any(|managed| managed.allows(&candidate_pattern))
{
invalid.push(entry.clone());
}
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{invalid:?}"),
"subset of managed allowed_domains",
))
}
})?;
}
}
}
if let Some(denied_domains) = &constraints.denied_domains {
@@ -240,24 +293,45 @@ pub fn validate_policy_against_constraints(
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
validate(config.network.denied_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| !candidate_set.contains(*entry))
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
"missing managed denied_domains entries",
format!("{missing:?}"),
))
match constraints.denylist_expansion_enabled {
Some(false) => {
validate(config.network.denied_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
if candidate_set == required_set {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
format!("{candidate:?}"),
"must match managed denied_domains",
))
}
})?;
}
})?;
Some(true) | None => {
validate(config.network.denied_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| !candidate_set.contains(*entry))
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
"missing managed denied_domains entries",
format!("{missing:?}"),
))
}
})?;
}
}
}
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {