mirror of
https://github.com/openai/codex.git
synced 2026-05-16 17:23:57 +00:00
## Summary This PR replaces the legacy network allow/deny list model with explicit rule maps for domains and unix sockets across managed requirements, permissions profiles, the network proxy config, and the app server protocol. Concretely, it: - introduces typed domain (`allow` / `deny`) and unix socket permission (`allow` / `none`) entries instead of separate `allowed_domains`, `denied_domains`, and `allow_unix_sockets` lists - updates config loading, managed requirements merging, and exec-policy overlays to read and upsert rule entries consistently - exposes the new shape through protocol/schema outputs, debug surfaces, and app-server config APIs - rejects the legacy list-based keys and updates docs/tests to reflect the new config format ## Why The previous representation split related network policy across multiple parallel lists, which made merging and overriding rules harder to reason about. Moving to explicit keyed permission maps gives us a single source of truth per host/socket entry, makes allow/deny precedence clearer, and gives protocol consumers access to the full rule state instead of derived projections only. ## Backward Compatibility ### Backward compatible - Managed requirements still accept the legacy `experimental_network.allowed_domains`, `experimental_network.denied_domains`, and `experimental_network.allow_unix_sockets` fields. They are normalized into the new canonical `domains` and `unix_sockets` maps internally. - App-server v2 still deserializes legacy `allowedDomains`, `deniedDomains`, and `allowUnixSockets` payloads, so older clients can continue reading managed network requirements. - App-server v2 responses still populate `allowedDomains`, `deniedDomains`, and `allowUnixSockets` as legacy compatibility views derived from the canonical maps. - `managed_allowed_domains_only` keeps the same behavior after normalization. Legacy managed allowlists still participate in the same enforcement path as canonical `domains` entries. ### Not backward compatible - Permissions profiles under `[permissions.<profile>.network]` no longer accept the legacy list-based keys. Those configs must use the canonical `[domains]` and `[unix_sockets]` tables instead of `allowed_domains`, `denied_domains`, or `allow_unix_sockets`. - Managed `experimental_network` config cannot mix canonical and legacy forms in the same block. For example, `domains` cannot be combined with `allowed_domains` or `denied_domains`, and `unix_sockets` cannot be combined with `allow_unix_sockets`. - The canonical format can express explicit `"none"` entries for unix sockets, but those entries do not round-trip through the legacy compatibility fields because the legacy fields only represent allow/deny lists. ## Testing `/target/debug/codex sandbox macos --log-denials /bin/zsh -c 'curl https://www.example.com' ` gives 200 with config ``` [permissions.workspace.network.domains] "www.example.com" = "allow" ``` and fails when set to deny: `curl: (56) CONNECT tunnel failed, response 403`. Also tested backward compatibility path by verifying that adding the following to `/etc/codex/requirements.toml` works: ``` [experimental_network] allowed_domains = ["www.example.com"] ```
219 lines
7.0 KiB
Rust
219 lines
7.0 KiB
Rust
use crate::codex::TurnContext;
|
|
use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT;
|
|
use crate::shell::Shell;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::TurnContextNetworkItem;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename = "environment_context", rename_all = "snake_case")]
|
|
pub(crate) struct EnvironmentContext {
|
|
pub cwd: Option<PathBuf>,
|
|
pub shell: Shell,
|
|
pub current_date: Option<String>,
|
|
pub timezone: Option<String>,
|
|
pub network: Option<NetworkContext>,
|
|
pub subagents: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub(crate) struct NetworkContext {
|
|
allowed_domains: Vec<String>,
|
|
denied_domains: Vec<String>,
|
|
}
|
|
|
|
impl EnvironmentContext {
|
|
pub fn new(
|
|
cwd: Option<PathBuf>,
|
|
shell: Shell,
|
|
current_date: Option<String>,
|
|
timezone: Option<String>,
|
|
network: Option<NetworkContext>,
|
|
subagents: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
cwd,
|
|
shell,
|
|
current_date,
|
|
timezone,
|
|
network,
|
|
subagents,
|
|
}
|
|
}
|
|
|
|
/// Compares two environment contexts, ignoring the shell. Useful when
|
|
/// comparing turn to turn, since the initial environment_context will
|
|
/// include the shell, and then it is not configurable from turn to turn.
|
|
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
|
let EnvironmentContext {
|
|
cwd,
|
|
current_date,
|
|
timezone,
|
|
network,
|
|
subagents,
|
|
shell: _,
|
|
} = other;
|
|
self.cwd == *cwd
|
|
&& self.current_date == *current_date
|
|
&& self.timezone == *timezone
|
|
&& self.network == *network
|
|
&& self.subagents == *subagents
|
|
}
|
|
|
|
pub fn diff_from_turn_context_item(
|
|
before: &TurnContextItem,
|
|
after: &TurnContext,
|
|
shell: &Shell,
|
|
) -> Self {
|
|
let before_network = Self::network_from_turn_context_item(before);
|
|
let after_network = Self::network_from_turn_context(after);
|
|
let cwd = if before.cwd.as_path() != after.cwd.as_path() {
|
|
Some(after.cwd.to_path_buf())
|
|
} else {
|
|
None
|
|
};
|
|
let current_date = after.current_date.clone();
|
|
let timezone = after.timezone.clone();
|
|
let network = if before_network != after_network {
|
|
after_network
|
|
} else {
|
|
before_network
|
|
};
|
|
EnvironmentContext::new(
|
|
cwd,
|
|
shell.clone(),
|
|
current_date,
|
|
timezone,
|
|
network,
|
|
/*subagents*/ None,
|
|
)
|
|
}
|
|
|
|
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
|
Self::new(
|
|
Some(turn_context.cwd.to_path_buf()),
|
|
shell.clone(),
|
|
turn_context.current_date.clone(),
|
|
turn_context.timezone.clone(),
|
|
Self::network_from_turn_context(turn_context),
|
|
/*subagents*/ None,
|
|
)
|
|
}
|
|
|
|
pub fn from_turn_context_item(turn_context_item: &TurnContextItem, shell: &Shell) -> Self {
|
|
Self::new(
|
|
Some(turn_context_item.cwd.clone()),
|
|
shell.clone(),
|
|
turn_context_item.current_date.clone(),
|
|
turn_context_item.timezone.clone(),
|
|
Self::network_from_turn_context_item(turn_context_item),
|
|
/*subagents*/ None,
|
|
)
|
|
}
|
|
|
|
pub fn with_subagents(mut self, subagents: String) -> Self {
|
|
if !subagents.is_empty() {
|
|
self.subagents = Some(subagents);
|
|
}
|
|
self
|
|
}
|
|
|
|
fn network_from_turn_context(turn_context: &TurnContext) -> Option<NetworkContext> {
|
|
let network = turn_context
|
|
.config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.network
|
|
.as_ref()?;
|
|
|
|
Some(NetworkContext {
|
|
allowed_domains: network
|
|
.domains
|
|
.as_ref()
|
|
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
|
|
.unwrap_or_default(),
|
|
denied_domains: network
|
|
.domains
|
|
.as_ref()
|
|
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
fn network_from_turn_context_item(
|
|
turn_context_item: &TurnContextItem,
|
|
) -> Option<NetworkContext> {
|
|
let TurnContextNetworkItem {
|
|
allowed_domains,
|
|
denied_domains,
|
|
} = turn_context_item.network.as_ref()?;
|
|
Some(NetworkContext {
|
|
allowed_domains: allowed_domains.clone(),
|
|
denied_domains: denied_domains.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl EnvironmentContext {
|
|
/// Serializes the environment context to XML. Libraries like `quick-xml`
|
|
/// require custom macros to handle Enums with newtypes, so we just do it
|
|
/// manually, to keep things simple. Output looks like:
|
|
///
|
|
/// ```xml
|
|
/// <environment_context>
|
|
/// <cwd>...</cwd>
|
|
/// <shell>...</shell>
|
|
/// </environment_context>
|
|
/// ```
|
|
pub fn serialize_to_xml(self) -> String {
|
|
let mut lines = Vec::new();
|
|
if let Some(cwd) = self.cwd {
|
|
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
|
}
|
|
|
|
let shell_name = self.shell.name();
|
|
lines.push(format!(" <shell>{shell_name}</shell>"));
|
|
if let Some(current_date) = self.current_date {
|
|
lines.push(format!(" <current_date>{current_date}</current_date>"));
|
|
}
|
|
if let Some(timezone) = self.timezone {
|
|
lines.push(format!(" <timezone>{timezone}</timezone>"));
|
|
}
|
|
match self.network {
|
|
Some(ref network) => {
|
|
lines.push(" <network enabled=\"true\">".to_string());
|
|
for allowed in &network.allowed_domains {
|
|
lines.push(format!(" <allowed>{allowed}</allowed>"));
|
|
}
|
|
for denied in &network.denied_domains {
|
|
lines.push(format!(" <denied>{denied}</denied>"));
|
|
}
|
|
lines.push(" </network>".to_string());
|
|
}
|
|
None => {
|
|
// TODO(mbolin): Include this line if it helps the model.
|
|
// lines.push(" <network enabled=\"false\" />".to_string());
|
|
}
|
|
}
|
|
if let Some(subagents) = self.subagents {
|
|
lines.push(" <subagents>".to_string());
|
|
lines.extend(subagents.lines().map(|line| format!(" {line}")));
|
|
lines.push(" </subagents>".to_string());
|
|
}
|
|
ENVIRONMENT_CONTEXT_FRAGMENT.wrap(lines.join("\n"))
|
|
}
|
|
}
|
|
|
|
impl From<EnvironmentContext> for ResponseItem {
|
|
fn from(ec: EnvironmentContext) -> Self {
|
|
ENVIRONMENT_CONTEXT_FRAGMENT.into_message(ec.serialize_to_xml())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "environment_context_tests.rs"]
|
|
mod tests;
|