mirror of
https://github.com/openai/codex.git
synced 2026-04-26 07:35:29 +00:00
feat(core): persist network approvals in execpolicy (#12357)
## Summary Persist network approval allow/deny decisions as `network_rule(...)` entries in execpolicy (not proxy config) It adds `network_rule` parsing + append support in `codex-execpolicy`, including `decision="prompt"` (parse-only; not compiled into proxy allow/deny lists) - compile execpolicy network rules into proxy allow/deny lists and update the live proxy state on approval - preserve requirements execpolicy `network_rule(...)` entries when merging with file-based execpolicy - reject broad wildcard hosts (for example `*`) for persisted `network_rule(...)`
This commit is contained in:
@@ -10,7 +10,6 @@ use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::state::NetworkProxyConstraintError;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
#[cfg(test)]
|
||||
use crate::state::build_config_state;
|
||||
use crate::state::validate_policy_against_constraints;
|
||||
use anyhow::Context;
|
||||
@@ -500,6 +499,70 @@ impl NetworkProxyState {
|
||||
}
|
||||
}
|
||||
|
||||
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(()),
|
||||
@@ -527,6 +590,46 @@ impl NetworkProxyState {
|
||||
}
|
||||
}
|
||||
|
||||
#[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")
|
||||
}
|
||||
@@ -695,6 +798,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 blocked_snapshot_does_not_consume_entries() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
||||
Reference in New Issue
Block a user