Config surface

New config key: [network_proxy.policy].allow_unix_sockets (string array), stored/edited via network_proxy.rs.
Entries support:
SSH_AUTH_SOCK / ${SSH_AUTH_SOCK}
preset aliases: ssh-agent, ssh_auth_sock, ssh_auth_socket
Entries are resolved at runtime to canonical absolute socket paths before generating Seatbelt rules.
macOS Seatbelt integration

seatbelt.rs now:
allows only loopback proxy ports (localhost:<port>) + explicitly allowed unix socket paths
does not emit per-domain (remote tcp ...) rules (those break under sandbox-exec)
Unix socket allowlist resolution is done via network_proxy::resolve_unix_socket_allowlist(...).
Prompt-on-deny UX (TUI)

When an exec approval happens and the command appears to need the SSH agent socket (ssh/scp/sftp/ssh-add, or git with ssh-style remotes), and the socket isn’t already allowed:
TUI shows an approval modal for the unix socket.
Allow for session: writes the resolved socket path to config (and removes it on exit, like session domain approvals).
Allow always: writes SSH_AUTH_SOCK to allow_unix_sockets for portability across restarts.
This commit is contained in:
viyatb-oai
2025-12-19 23:57:18 -08:00
parent 73430c462f
commit e47d02ab27
16 changed files with 712 additions and 118 deletions

View File

@@ -212,10 +212,7 @@ fn resolve_proxy_endpoints(network_proxy: &NetworkProxyConfig) -> ProxyEndpoints
})
};
let mut socks = if is_socks {
Some(ProxyEndpoint {
host: host.clone(),
port,
})
Some(ProxyEndpoint { host, port })
} else {
None
};

View File

@@ -14,6 +14,7 @@ use shlex::split as shlex_split;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use toml_edit::Array as TomlArray;
use toml_edit::DocumentMut;
use toml_edit::InlineTable;
@@ -25,6 +26,7 @@ const NETWORK_PROXY_TABLE: &str = "network_proxy";
const NETWORK_PROXY_POLICY_TABLE: &str = "policy";
const ALLOWED_DOMAINS_KEY: &str = "allowed_domains";
const DENIED_DOMAINS_KEY: &str = "denied_domains";
const ALLOW_UNIX_SOCKETS_KEY: &str = "allow_unix_sockets";
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkProxyBlockedRequest {
@@ -144,6 +146,14 @@ pub fn add_denied_domain(config_path: &Path, host: &str) -> Result<bool> {
update_domain_list(config_path, host, DomainListKind::Deny)
}
pub fn add_allowed_unix_socket(config_path: &Path, socket: &str) -> Result<bool> {
update_unix_socket_list(config_path, socket, UnixSocketListKind::Allow)
}
pub fn remove_allowed_unix_socket(config_path: &Path, socket: &str) -> Result<bool> {
update_unix_socket_list(config_path, socket, UnixSocketListKind::Remove)
}
#[derive(Debug, Clone, Copy)]
pub struct DomainState {
pub allowed: bool,
@@ -193,6 +203,20 @@ pub fn set_domain_state(config_path: &Path, host: &str, state: DomainState) -> R
Ok(changed)
}
pub fn unix_socket_allowed(config_path: &Path, socket_path: &Path) -> Result<bool> {
let policy = load_network_policy(config_path)?;
let allowed = resolve_unix_socket_allowlist(&policy.allow_unix_sockets);
if allowed.is_empty() {
return Ok(false);
}
let canonical_socket = socket_path
.canonicalize()
.unwrap_or_else(|_| socket_path.to_path_buf());
Ok(allowed
.iter()
.any(|allowed_path| canonical_socket.starts_with(allowed_path)))
}
pub fn should_preflight_network(
network_proxy: &NetworkProxyConfig,
sandbox_policy: &SandboxPolicy,
@@ -239,6 +263,43 @@ pub fn preflight_blocked_request_if_enabled(
}
}
#[derive(Debug, Clone)]
pub struct UnixSocketPreflightMatch {
/// Socket path that needs to be allowed (canonicalized when possible).
pub socket_path: PathBuf,
/// Suggested config entry to add for a persistent allow (e.g. `$SSH_AUTH_SOCK`).
pub suggested_allow_entry: String,
pub reason: String,
}
pub fn preflight_blocked_unix_socket_if_enabled(
network_proxy: &NetworkProxyConfig,
sandbox_policy: &SandboxPolicy,
command: &[String],
) -> Result<Option<UnixSocketPreflightMatch>> {
if !cfg!(target_os = "macos") {
return Ok(None);
}
if !should_preflight_network(network_proxy, sandbox_policy) {
return Ok(None);
}
let Some(socket_path) = ssh_auth_sock_if_needed(command) else {
return Ok(None);
};
let config_path = config::default_config_path()?;
if unix_socket_allowed(&config_path, &socket_path)? {
return Ok(None);
}
Ok(Some(UnixSocketPreflightMatch {
socket_path,
suggested_allow_entry: "$SSH_AUTH_SOCK".to_string(),
reason: "not_allowed_unix_socket".to_string(),
}))
}
pub fn apply_mitm_ca_env_if_enabled(
env_map: &mut HashMap<String, String>,
network_proxy: &NetworkProxyConfig,
@@ -397,6 +458,34 @@ fn update_domain_list(config_path: &Path, host: &str, list: DomainListKind) -> R
Ok(changed)
}
#[derive(Copy, Clone)]
enum UnixSocketListKind {
Allow,
Remove,
}
fn update_unix_socket_list(
config_path: &Path,
socket: &str,
action: UnixSocketListKind,
) -> Result<bool> {
let socket = socket.trim();
if socket.is_empty() {
return Err(anyhow!("socket is empty"));
}
let mut doc = load_document(config_path)?;
let policy = ensure_policy_table(&mut doc);
let list = ensure_array(policy, ALLOW_UNIX_SOCKETS_KEY);
let changed = match action {
UnixSocketListKind::Allow => add_domain(list, socket),
UnixSocketListKind::Remove => remove_domain(list, socket),
};
if changed {
write_document(config_path, &doc)?;
}
Ok(changed)
}
fn load_document(path: &Path) -> Result<DocumentMut> {
if !path.exists() {
return Ok(DocumentMut::new());
@@ -431,6 +520,88 @@ pub(crate) struct NetworkPolicy {
pub(crate) allow_local_binding: bool,
}
pub(crate) fn resolve_unix_socket_allowlist(entries: &[String]) -> Vec<PathBuf> {
let mut resolved = Vec::new();
let mut seen = HashSet::new();
for entry in entries {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
for candidate in resolve_unix_socket_entry(entry) {
if !seen.insert(candidate.clone()) {
continue;
}
resolved.push(candidate);
}
}
resolved.sort();
resolved
}
fn resolve_unix_socket_entry(entry: &str) -> Vec<PathBuf> {
// Presets are intentionally simple: they resolve to a path (or set of paths)
// and are ultimately translated into Seatbelt `subpath` rules.
let entry = entry.trim();
if entry.is_empty() {
return Vec::new();
}
let mut candidates: Vec<String> = Vec::new();
match entry {
"ssh-agent" | "ssh_auth_sock" | "ssh_auth_socket" => {
if let Some(value) = std::env::var_os("SSH_AUTH_SOCK") {
candidates.push(value.to_string_lossy().to_string());
}
}
_ => {
if let Some(var) = entry.strip_prefix('$') {
candidates.extend(resolve_env_unix_socket(var));
} else if entry.starts_with("${") && entry.ends_with('}') {
candidates.extend(resolve_env_unix_socket(&entry[2..entry.len() - 1]));
} else {
candidates.push(entry.to_string());
}
}
}
candidates
.into_iter()
.filter_map(|candidate| parse_unix_socket_candidate(&candidate))
.collect()
}
fn resolve_env_unix_socket(var: &str) -> Vec<String> {
let var = var.trim();
if var.is_empty() {
return Vec::new();
}
std::env::var_os(var)
.map(|value| vec![value.to_string_lossy().to_string()])
.unwrap_or_default()
}
fn parse_unix_socket_candidate(candidate: &str) -> Option<PathBuf> {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return None;
}
let path = if let Some(rest) = trimmed.strip_prefix("unix://") {
rest
} else if let Some(rest) = trimmed.strip_prefix("unix:") {
rest
} else {
trimmed
};
let path = PathBuf::from(path);
if !path.is_absolute() {
return None;
}
Some(path.canonicalize().unwrap_or(path))
}
pub(crate) fn load_network_policy(config_path: &Path) -> Result<NetworkPolicy> {
if !config_path.exists() {
return Ok(NetworkPolicy::default());
@@ -478,6 +649,39 @@ fn extract_hosts_from_command(command: &[String]) -> Vec<String> {
hosts.into_iter().collect()
}
fn ssh_auth_sock_if_needed(command: &[String]) -> Option<PathBuf> {
let Some(cmd0) = command.first() else {
return None;
};
let cmd = std::path::Path::new(cmd0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
let needs_sock = match cmd {
"ssh" | "scp" | "sftp" | "ssh-add" => true,
"git" => command
.iter()
.skip(1)
.any(|arg| arg.contains("ssh://") || looks_like_scp_host(arg)),
_ => false,
};
if !needs_sock {
return None;
}
let sock = std::env::var_os("SSH_AUTH_SOCK")?;
let sock = sock.to_string_lossy().to_string();
parse_unix_socket_candidate(&sock)
}
fn looks_like_scp_host(value: &str) -> bool {
// e.g. git@github.com:owner/repo.git
let value = value.trim();
if value.is_empty() || value.starts_with('-') {
return false;
}
value.contains('@') && value.contains(':') && !value.contains("://")
}
fn extract_hosts_from_tokens(tokens: &[String], hosts: &mut HashSet<String>) {
let (cmd0, args) = match tokens.split_first() {
Some((cmd0, args)) => (cmd0.as_str(), args),

View File

@@ -2,7 +2,6 @@
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::CStr;
use std::path::Path;
use std::path::PathBuf;
@@ -55,50 +54,6 @@ pub async fn spawn_command_under_seatbelt(
.await
}
fn proxy_allowlist_from_env(env: &HashMap<String, String>) -> Vec<String> {
let mut allowlist = Vec::new();
let mut seen = HashSet::new();
for key in PROXY_ENV_KEYS {
let Some(proxy_url) = env.get(*key) else {
continue;
};
let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else {
continue;
};
for entry in proxy_allowlist_entries(&host, port) {
if seen.insert(entry.clone()) {
allowlist.push(entry);
}
}
}
allowlist
}
fn proxy_allowlist_entries(host: &str, port: i64) -> Vec<String> {
let mut entries = Vec::new();
let is_loopback = is_loopback_host(host);
if is_loopback {
for candidate in ["localhost", "127.0.0.1", "::1"] {
entries.push(format_proxy_host_port(candidate, port));
}
} else {
entries.push(format_proxy_host_port(host, port));
}
entries
}
fn format_proxy_host_port(host: &str, port: i64) -> String {
if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
fn is_loopback_host(host: &str) -> bool {
let host_lower = host.to_ascii_lowercase();
host_lower == "localhost" || host == "127.0.0.1" || host == "::1"
@@ -108,16 +63,21 @@ fn is_loopback_host(host: &str) -> bool {
struct ProxyPorts {
http: Vec<u16>,
socks: Vec<u16>,
has_proxy_env: bool,
has_non_loopback_proxy_env: bool,
}
fn proxy_ports_from_env(env: &HashMap<String, String>) -> ProxyPorts {
let mut http_ports = BTreeSet::new();
let mut socks_ports = BTreeSet::new();
let mut has_proxy_env = false;
let mut has_non_loopback_proxy_env = false;
for key in PROXY_ENV_KEYS {
let Some(proxy_url) = env.get(*key) else {
continue;
};
has_proxy_env = true;
let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else {
continue;
};
@@ -125,6 +85,7 @@ fn proxy_ports_from_env(env: &HashMap<String, String>) -> ProxyPorts {
continue;
};
if !is_loopback_host(&host) {
has_non_loopback_proxy_env = true;
continue;
}
let scheme = proxy_url_scheme(proxy_url).unwrap_or("http");
@@ -138,6 +99,8 @@ fn proxy_ports_from_env(env: &HashMap<String, String>) -> ProxyPorts {
ProxyPorts {
http: http_ports.into_iter().collect(),
socks: socks_ports.into_iter().collect(),
has_proxy_env,
has_non_loopback_proxy_env,
}
}
@@ -153,31 +116,16 @@ fn normalize_proxy_port(port: i64) -> Option<u16> {
}
}
fn normalize_unix_socket_path(path: &str) -> Option<String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
let path_buf = PathBuf::from(trimmed);
let normalized = if path_buf.is_absolute() {
path_buf.canonicalize().unwrap_or(path_buf)
} else {
path_buf
};
Some(normalized.to_string_lossy().to_string())
}
fn escape_sbpl_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn build_network_policy(
proxy_allowlist: &[String],
policy: &network_proxy::NetworkPolicy,
proxy_ports: &ProxyPorts,
) -> String {
fn build_network_policy(policy: &network_proxy::NetworkPolicy, proxy_ports: &ProxyPorts) -> String {
let mut network_rules = String::from("; Network\n");
if proxy_allowlist.is_empty() {
// On macOS, `sandbox-exec` only accepts `localhost` or `*` in network
// addresses. We use loopback proxy ports + the network proxy itself to
// enforce per-domain policy and prompting.
if !proxy_ports.has_proxy_env {
network_rules.push_str("(allow network*)\n");
return format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}");
}
@@ -189,15 +137,9 @@ fn build_network_policy(
}
if !policy.allow_unix_sockets.is_empty() {
let mut seen = HashSet::new();
for socket_path in &policy.allow_unix_sockets {
let Some(normalized) = normalize_unix_socket_path(socket_path) else {
continue;
};
if !seen.insert(normalized.clone()) {
continue;
}
let escaped = escape_sbpl_string(&normalized);
for socket_path in network_proxy::resolve_unix_socket_allowlist(&policy.allow_unix_sockets)
{
let escaped = escape_sbpl_string(&socket_path.to_string_lossy());
network_rules.push_str(&format!("(allow network* (subpath \"{escaped}\"))\n"));
}
}
@@ -226,12 +168,10 @@ fn build_network_policy(
));
}
let mut outbound = String::from("(allow network-outbound\n");
for endpoint in proxy_allowlist {
outbound.push_str(&format!(" (remote tcp \"{endpoint}\")\n"));
if proxy_ports.has_non_loopback_proxy_env {
network_rules
.push_str("; NOTE: Non-loopback proxies are not supported under `sandbox-exec`.\n");
}
outbound.push_str(")\n");
network_rules.push_str(&outbound);
format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}")
}
@@ -307,13 +247,12 @@ pub(crate) fn create_seatbelt_command_args(
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
let network_policy = if sandbox_policy.has_full_network_access() {
let proxy_allowlist = proxy_allowlist_from_env(env);
let proxy_ports = proxy_ports_from_env(env);
let policy = config::default_config_path()
.ok()
.and_then(|path| network_proxy::load_network_policy(&path).ok())
.unwrap_or_default();
build_network_policy(&proxy_allowlist, &policy, &proxy_ports)
build_network_policy(&policy, &proxy_ports)
} else {
String::new()
};
@@ -382,17 +321,24 @@ mod tests {
impl CodexHomeGuard {
fn new(path: &Path) -> Self {
let previous = std::env::var("CODEX_HOME").ok();
std::env::set_var("CODEX_HOME", path);
// SAFETY: these tests execute serially, and we restore the original value in Drop.
unsafe {
std::env::set_var("CODEX_HOME", path);
}
Self { previous }
}
}
impl Drop for CodexHomeGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.take() {
std::env::set_var("CODEX_HOME", previous);
} else {
std::env::remove_var("CODEX_HOME");
// SAFETY: these tests execute serially, and we restore the original value before other
// tests run.
unsafe {
if let Some(previous) = self.previous.take() {
std::env::set_var("CODEX_HOME", previous);
} else {
std::env::remove_var("CODEX_HOME");
}
}
}
}
@@ -506,6 +452,7 @@ mod tests {
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(
"sandbox_mode = \"read-only\"\n",
String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")),
@@ -516,8 +463,14 @@ mod tests {
"command to write {} should fail under seatbelt",
&config_toml.display()
);
if stderr.starts_with("sandbox-exec: sandbox_apply:") {
// Some environments (including Codex's own test harness) run the process under a
// Seatbelt sandbox already, which prevents nested `sandbox-exec` usage. In that case,
// we can still validate policy generation but cannot validate enforcement.
return;
}
assert_eq!(
String::from_utf8_lossy(&output.stderr),
stderr,
format!("bash: {}: Operation not permitted\n", config_toml.display()),
);
@@ -730,8 +683,12 @@ mod tests {
"expected seatbelt policy to allow local proxy outbound"
);
assert!(
policy_text.contains("(remote tcp \"127.0.0.1:3128\")"),
"expected seatbelt policy to include the proxy allowlist"
!policy_text.contains("(remote tcp"),
"`sandbox-exec` network addresses only support `localhost` or `*`, so we must not emit host allowlists"
);
assert!(
!policy_text.contains("127.0.0.1:3128"),
"seatbelt policy must not include numeric loopback hosts (it will fail to parse)"
);
assert!(
!policy_text.contains("localhost:*"),

View File

@@ -21,7 +21,6 @@ use crate::protocol::EventMsg;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::ExecCommandSource;
use crate::protocol::SandboxPolicy;
use crate::protocol::TaskStartedEvent;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::SandboxPermissions;

View File

@@ -318,6 +318,8 @@ pub(crate) struct App {
network_proxy_pending: HashSet<String>,
network_proxy_session_restore: HashMap<String, network_proxy::DomainState>,
unix_socket_pending: HashSet<String>,
unix_socket_session_restore: HashSet<String>,
}
impl App {
@@ -450,6 +452,8 @@ impl App {
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
};
if app.config.network_proxy.enabled && app.config.network_proxy.prompt_on_block {
@@ -1164,6 +1168,32 @@ impl App {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(lines, "N E T".to_string()));
}
ApprovalRequest::UnixSocket { request } => {
let mut lines = Vec::new();
if !request.label.trim().is_empty() {
lines.push(Line::from(vec![
"Resource: ".into(),
request.label.clone().bold(),
]));
}
if !request.socket_path.trim().is_empty() {
lines.push(Line::from(vec![
"Socket: ".into(),
request.socket_path.clone().dim(),
]));
}
if !request.allow_entry.trim().is_empty() {
lines.push(Line::from(vec![
"Allow entry: ".into(),
request.allow_entry.clone().dim(),
]));
}
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(
lines,
"S O C K E T".to_string(),
));
}
ApprovalRequest::McpElicitation {
server_name,
message,
@@ -1191,20 +1221,20 @@ impl App {
return Ok(true);
}
let reason = request.reason.trim();
if reason.eq_ignore_ascii_case("not_allowed") {
if let Ok(config_path) = codex_config_path() {
match network_proxy::preflight_host(&config_path, &host) {
Ok(None) => {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
Ok(Some(_)) => {}
Err(err) => {
tracing::debug!(
error = %err,
"network proxy preflight host check failed"
);
}
if reason.eq_ignore_ascii_case("not_allowed")
&& let Ok(config_path) = codex_config_path()
{
match network_proxy::preflight_host(&config_path, &host) {
Ok(None) => {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
Ok(Some(_)) => {}
Err(err) => {
tracing::debug!(
error = %err,
"network proxy preflight host check failed"
);
}
}
}
@@ -1221,6 +1251,30 @@ impl App {
self.network_proxy_pending.insert(host);
self.chat_widget.on_network_approval_request(request);
}
AppEvent::UnixSocketApprovalRequest(request) => {
if !self.config.network_proxy.prompt_on_block {
return Ok(true);
}
let socket_path = request.socket_path.trim().to_string();
if socket_path.is_empty() || self.unix_socket_pending.contains(&socket_path) {
return Ok(true);
}
if let Ok(config_path) = codex_config_path() {
let socket = std::path::Path::new(&socket_path);
match network_proxy::unix_socket_allowed(&config_path, socket) {
Ok(true) => {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
Ok(false) => {}
Err(err) => {
tracing::debug!(error = %err, "unix socket preflight check failed");
}
}
}
self.unix_socket_pending.insert(socket_path);
self.chat_widget.on_unix_socket_approval_request(request);
}
AppEvent::NetworkProxyDecision { host, decision } => {
let host = host.trim().to_string();
if host.is_empty() {
@@ -1321,6 +1375,71 @@ impl App {
self.chat_widget.reject_pending_exec_approval();
}
}
AppEvent::UnixSocketDecision {
socket_path,
allow_entry,
decision,
} => {
let socket_path = socket_path.trim().to_string();
if socket_path.is_empty() {
return Ok(true);
}
self.unix_socket_pending.remove(&socket_path);
let config_path = codex_config_path()?;
let mut should_resume_exec = matches!(
decision,
crate::app_event::UnixSocketDecision::AllowSession
| crate::app_event::UnixSocketDecision::AllowAlways
);
match decision {
crate::app_event::UnixSocketDecision::AllowSession => {
match network_proxy::add_allowed_unix_socket(&config_path, &socket_path) {
Ok(changed) => {
if changed {
self.unix_socket_session_restore.insert(socket_path.clone());
}
self.chat_widget
.add_unix_socket_session_allow(socket_path.clone());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to allow Unix socket {socket_path} for this session: {err}"
));
should_resume_exec = false;
}
}
}
crate::app_event::UnixSocketDecision::AllowAlways => {
if allow_entry.trim().is_empty() {
self.chat_widget.add_error_message(
"Failed to allow Unix socket permanently: missing allow entry"
.to_string(),
);
should_resume_exec = false;
} else {
match network_proxy::add_allowed_unix_socket(&config_path, &allow_entry)
{
Ok(_) => {
self.chat_widget
.add_unix_socket_session_allow(socket_path.clone());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to add Unix socket allow entry {allow_entry}: {err}"
));
should_resume_exec = false;
}
}
}
}
crate::app_event::UnixSocketDecision::Deny => {}
}
if should_resume_exec {
self.chat_widget.resume_pending_exec_approval();
} else {
self.chat_widget.reject_pending_exec_approval();
}
}
}
Ok(true)
}
@@ -1348,7 +1467,9 @@ impl App {
}
async fn restore_network_proxy_approvals(&mut self) -> Result<()> {
if self.network_proxy_session_restore.is_empty() {
if self.network_proxy_session_restore.is_empty()
&& self.unix_socket_session_restore.is_empty()
{
return Ok(());
}
let config_path = codex_config_path()?;
@@ -1381,6 +1502,10 @@ impl App {
.await
.map_err(|err| color_eyre::eyre::eyre!(err))?;
}
for socket_path in self.unix_socket_session_restore.drain() {
let _ = network_proxy::remove_allowed_unix_socket(&config_path, &socket_path);
}
Ok(())
}
@@ -1566,6 +1691,8 @@ mod tests {
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
}
}
@@ -1608,6 +1735,8 @@ mod tests {
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
},
rx,
op_rx,

View File

@@ -175,12 +175,22 @@ pub(crate) enum AppEvent {
/// Prompt for a blocked network request from the proxy.
NetworkProxyApprovalRequest(NetworkProxyBlockedRequest),
/// Prompt to allow a Unix socket path inside the sandbox (macOS only).
UnixSocketApprovalRequest(UnixSocketApprovalRequest),
/// User decision for a blocked network request.
NetworkProxyDecision {
host: String,
decision: NetworkProxyDecision,
},
/// User decision for a Unix socket approval request.
UnixSocketDecision {
socket_path: String,
allow_entry: String,
decision: UnixSocketDecision,
},
/// Open the feedback note entry overlay after the user selects a category.
OpenFeedbackNote {
category: FeedbackCategory,
@@ -200,6 +210,20 @@ pub(crate) enum NetworkProxyDecision {
Deny,
}
#[derive(Debug, Clone)]
pub(crate) struct UnixSocketApprovalRequest {
pub label: String,
pub socket_path: String,
pub allow_entry: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnixSocketDecision {
AllowSession,
AllowAlways,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,

View File

@@ -3,6 +3,8 @@ use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::NetworkProxyDecision;
use crate::app_event::UnixSocketApprovalRequest;
use crate::app_event::UnixSocketDecision;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
@@ -57,6 +59,9 @@ pub(crate) enum ApprovalRequest {
Network {
request: NetworkProxyBlockedRequest,
},
UnixSocket {
request: UnixSocketApprovalRequest,
},
McpElicitation {
server_name: String,
request_id: RequestId,
@@ -129,6 +134,9 @@ impl ApprovalOverlay {
network_options(*preflight_only),
"Allow network access to this domain?".to_string(),
),
ApprovalVariant::UnixSocket { label, .. } => {
(unix_socket_options(), format!("Allow access to {label}?"))
}
ApprovalVariant::McpElicitation { server_name, .. } => (
elicitation_options(),
format!("{server_name} needs your approval."),
@@ -187,6 +195,16 @@ impl ApprovalOverlay {
(ApprovalVariant::Network { host, .. }, ApprovalDecision::Network(decision)) => {
self.handle_network_decision(host, *decision);
}
(
ApprovalVariant::UnixSocket {
socket_path,
allow_entry,
..
},
ApprovalDecision::UnixSocket(decision),
) => {
self.handle_unix_socket_decision(socket_path, allow_entry, *decision);
}
(
ApprovalVariant::McpElicitation {
server_name,
@@ -229,6 +247,22 @@ impl ApprovalOverlay {
});
}
fn handle_unix_socket_decision(
&self,
socket_path: &str,
allow_entry: &str,
decision: UnixSocketDecision,
) {
let cell =
history_cell::new_unix_socket_approval_decision_cell(socket_path.to_string(), decision);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::UnixSocketDecision {
socket_path: socket_path.to_string(),
allow_entry: allow_entry.to_string(),
decision,
});
}
fn handle_elicitation_decision(
&self,
server_name: &str,
@@ -311,6 +345,17 @@ impl BottomPaneView for ApprovalOverlay {
ApprovalVariant::Network { host, .. } => {
self.handle_network_decision(host, NetworkProxyDecision::Deny);
}
ApprovalVariant::UnixSocket {
socket_path,
allow_entry,
..
} => {
self.handle_unix_socket_decision(
socket_path,
allow_entry,
UnixSocketDecision::Deny,
);
}
ApprovalVariant::McpElicitation {
server_name,
request_id,
@@ -463,6 +508,35 @@ impl From<ApprovalRequest> for ApprovalRequestState {
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
ApprovalRequest::UnixSocket { request } => {
let mut header: Vec<Line<'static>> = Vec::new();
if !request.label.trim().is_empty() {
header.push(Line::from(vec![
"Resource: ".into(),
request.label.clone().bold(),
]));
}
if !request.socket_path.trim().is_empty() {
header.push(Line::from(vec![
"Socket: ".into(),
request.socket_path.clone().dim(),
]));
}
if !request.allow_entry.trim().is_empty() {
header.push(Line::from(vec![
"Allow entry: ".into(),
request.allow_entry.clone().dim(),
]));
}
Self {
variant: ApprovalVariant::UnixSocket {
socket_path: request.socket_path,
allow_entry: request.allow_entry,
label: request.label,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
ApprovalRequest::McpElicitation {
server_name,
request_id,
@@ -520,6 +594,11 @@ enum ApprovalVariant {
host: String,
preflight_only: bool,
},
UnixSocket {
socket_path: String,
allow_entry: String,
label: String,
},
McpElicitation {
server_name: String,
request_id: RequestId,
@@ -530,6 +609,7 @@ enum ApprovalVariant {
enum ApprovalDecision {
Review(ReviewDecision),
Network(NetworkProxyDecision),
UnixSocket(UnixSocketDecision),
McpElicitation(ElicitationAction),
}
@@ -638,6 +718,29 @@ fn network_options(preflight_only: bool) -> Vec<ApprovalOption> {
options
}
fn unix_socket_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Allow for session".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('s'))],
},
ApprovalOption {
label: "Allow always (add to allowlist)".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Deny".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::Deny),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}
fn elicitation_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {

View File

@@ -88,6 +88,7 @@ use tokio::task::JoinHandle;
use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event::UnixSocketApprovalRequest;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BetaFeatureItem;
@@ -358,6 +359,7 @@ pub(crate) struct ChatWidget {
needs_final_message_separator: bool,
pending_exec_approval: Option<PendingExecApproval>,
network_proxy_session_allow: HashSet<String>,
unix_socket_session_allow: HashSet<String>,
last_rendered_width: std::cell::Cell<Option<usize>>,
// Feedback sink for /feedback
@@ -366,10 +368,15 @@ pub(crate) struct ChatWidget {
current_rollout_path: Option<PathBuf>,
}
enum PendingExecBlock {
NetworkHost(String),
UnixSocket(String),
}
struct PendingExecApproval {
id: String,
event: ExecApprovalRequestEvent,
host: String,
block: PendingExecBlock,
}
struct UserMessage {
@@ -865,6 +872,14 @@ impl ChatWidget {
);
}
pub(crate) fn on_unix_socket_approval_request(&mut self, request: UnixSocketApprovalRequest) {
let request2 = request.clone();
self.defer_or_handle(
|q| q.push_unix_socket_approval(request),
|s| s.handle_unix_socket_approval_request(request2),
);
}
fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
@@ -1207,6 +1222,18 @@ impl ChatWidget {
}
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
if self.pending_exec_approval.is_none()
&& let Some(request) = self.preflight_unix_socket_request(&ev.command)
{
self.pending_exec_approval = Some(PendingExecApproval {
id: id.clone(),
event: ev,
block: PendingExecBlock::UnixSocket(request.socket_path.clone()),
});
self.app_event_tx
.send(AppEvent::UnixSocketApprovalRequest(request));
return;
}
if self.pending_exec_approval.is_none()
&& let Some(mut request) = self.preflight_network_request(&ev.command)
{
@@ -1225,7 +1252,7 @@ impl ChatWidget {
self.pending_exec_approval = Some(PendingExecApproval {
id,
event: ev,
host: request.host.clone(),
block: PendingExecBlock::NetworkHost(request.host.clone()),
});
self.app_event_tx
.send(AppEvent::NetworkProxyApprovalRequest(request));
@@ -1261,16 +1288,21 @@ impl ChatWidget {
});
return;
}
self.show_exec_approval(pending.id, pending.event);
self.handle_exec_approval_now(pending.id, pending.event);
}
}
pub(crate) fn reject_pending_exec_approval(&mut self) {
if let Some(pending) = self.pending_exec_approval.take() {
self.add_to_history(history_cell::new_error_event(format!(
"Exec canceled because network access to {} was denied.",
pending.host
)));
let reason = match pending.block {
PendingExecBlock::NetworkHost(host) => {
format!("Exec canceled because network access to {host} was denied.")
}
PendingExecBlock::UnixSocket(socket_path) => {
format!("Exec canceled because Unix socket access to {socket_path} was denied.")
}
};
self.add_to_history(history_cell::new_error_event(reason));
self.submit_op(Op::ExecApproval {
id: pending.id,
decision: ReviewDecision::Denied,
@@ -1286,6 +1318,14 @@ impl ChatWidget {
self.network_proxy_session_allow.insert(host);
}
pub(crate) fn add_unix_socket_session_allow(&mut self, socket_path: String) {
let socket_path = socket_path.trim().to_string();
if socket_path.is_empty() {
return;
}
self.unix_socket_session_allow.insert(socket_path);
}
pub(crate) fn network_session_allow(&self) -> HashSet<String> {
self.network_proxy_session_allow.clone()
}
@@ -1303,6 +1343,14 @@ impl ChatWidget {
.contains(normalized.as_str())
}
fn is_unix_socket_session_allowed(&self, socket_path: &str) -> bool {
let socket_path = socket_path.trim();
if socket_path.is_empty() {
return false;
}
self.unix_socket_session_allow.contains(socket_path)
}
fn preflight_network_request(&self, command: &[String]) -> Option<NetworkProxyBlockedRequest> {
let blocked = match network_proxy::preflight_blocked_request_if_enabled(
&self.config.network_proxy,
@@ -1322,6 +1370,33 @@ impl ChatWidget {
Some(request)
}
fn preflight_unix_socket_request(
&self,
command: &[String],
) -> Option<UnixSocketApprovalRequest> {
let blocked = match network_proxy::preflight_blocked_unix_socket_if_enabled(
&self.config.network_proxy,
&self.config.sandbox_policy,
command,
) {
Ok(result) => result,
Err(err) => {
tracing::debug!(error = %err, "unix socket preflight failed");
None
}
};
let blocked = blocked?;
let socket_path = blocked.socket_path.to_string_lossy().to_string();
if self.is_unix_socket_session_allowed(&socket_path) {
return None;
}
Some(UnixSocketApprovalRequest {
label: "SSH agent socket".to_string(),
socket_path,
allow_entry: blocked.suggested_allow_entry,
})
}
pub(crate) fn handle_apply_patch_approval_now(
&mut self,
id: String,
@@ -1353,6 +1428,18 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn handle_unix_socket_approval_request(
&mut self,
request: UnixSocketApprovalRequest,
) {
self.flush_answer_stream_with_separator();
self.bottom_pane.push_approval_request(
ApprovalRequest::UnixSocket { request },
&self.config.features,
);
self.request_redraw();
}
pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) {
self.flush_answer_stream_with_separator();
@@ -1552,6 +1639,7 @@ impl ChatWidget {
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: HashSet::new(),
unix_socket_session_allow: HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
@@ -1640,6 +1728,7 @@ impl ChatWidget {
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: HashSet::new(),
unix_socket_session_allow: HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,

View File

@@ -10,6 +10,8 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_protocol::approvals::ElicitationRequestEvent;
use crate::app_event::UnixSocketApprovalRequest;
use super::ChatWidget;
#[derive(Debug)]
@@ -17,6 +19,7 @@ pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
NetworkApproval(NetworkProxyBlockedRequest),
UnixSocketApproval(UnixSocketApprovalRequest),
Elicitation(ElicitationRequestEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
@@ -60,6 +63,11 @@ impl InterruptManager {
.push_back(QueuedInterrupt::NetworkApproval(request));
}
pub(crate) fn push_unix_socket_approval(&mut self, request: UnixSocketApprovalRequest) {
self.queue
.push_back(QueuedInterrupt::UnixSocketApproval(request));
}
pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
@@ -94,6 +102,9 @@ impl InterruptManager {
QueuedInterrupt::NetworkApproval(request) => {
chat.handle_network_approval_request(request)
}
QueuedInterrupt::UnixSocketApproval(request) => {
chat.handle_unix_socket_approval_request(request)
}
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),

View File

@@ -407,6 +407,7 @@ fn make_chatwidget_manual(
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: std::collections::HashSet::new(),
unix_socket_session_allow: std::collections::HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback: codex_feedback::CodexFeedback::new(),
current_rollout_path: None,

View File

@@ -1,4 +1,5 @@
use crate::app_event::NetworkProxyDecision;
use crate::app_event::UnixSocketDecision;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -579,6 +580,50 @@ pub fn new_network_approval_decision_cell(
))
}
pub fn new_unix_socket_approval_decision_cell(
socket_path: String,
decision: UnixSocketDecision,
) -> Box<dyn HistoryCell> {
let socket_span = Span::from(socket_path).dim();
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
UnixSocketDecision::AllowSession => (
"".green(),
vec![
"You ".into(),
"approved".bold(),
" Unix socket access to ".into(),
socket_span,
" for this session".bold(),
],
),
UnixSocketDecision::AllowAlways => (
"".green(),
vec![
"You ".into(),
"approved".bold(),
" Unix socket access to ".into(),
socket_span,
" permanently".bold(),
],
),
UnixSocketDecision::Deny => (
"".red(),
vec![
"You ".into(),
"denied".bold(),
" Unix socket access to ".into(),
socket_span,
],
),
};
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
symbol,
" ",
))
}
/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {

View File

@@ -382,6 +382,11 @@ Notes:
- `no_proxy` entries bypass the proxy; defaults include localhost + private network ranges. Use sparingly because bypassed traffic is not filtered by the proxy policy.
- `[network_proxy.policy]` can optionally allow localhost binding or Unix socket access (macOS only) when proxy-restricted network access is active.
- When `prompt_on_block = true`, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow for the session, allow always (add to allowlist), or deny (add to denylist). Allow/deny decisions update `~/.codex/config.toml` under `[network_proxy.policy]`, then Codex calls `/reload`.
- On macOS, `network_proxy.policy.allow_unix_sockets` is useful for local IPC that relies on Unix domain sockets (most commonly the SSH agent). Entries can be:
- absolute socket paths (or directories containing sockets),
- `$SSH_AUTH_SOCK` / `${SSH_AUTH_SOCK}`,
- the preset `ssh-agent` (alias: `ssh_auth_sock`, `ssh_auth_socket`).
When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (e.g. `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH).
### tools.\*
@@ -984,7 +989,7 @@ Valid values:
| `network_proxy.policy.allowed_domains` | array<string> | Allowlist of domain patterns (denylist takes precedence). |
| `network_proxy.policy.denied_domains` | array<string> | Denylist of domain patterns (takes precedence over allowlist). |
| `network_proxy.policy.allow_local_binding` | boolean | Allow localhost binding when proxy-restricted (macOS only, default: false). |
| `network_proxy.policy.allow_unix_sockets` | array<string> | Allow specific Unix socket paths when proxy-restricted (macOS only, default: []). |
| `network_proxy.policy.allow_unix_sockets` | array<string> | Allow Unix socket paths when proxy-restricted (macOS only, default: []). Supports `$SSH_AUTH_SOCK` and the `ssh-agent` preset. |
| `network_proxy.mitm.enabled` | boolean | Enable HTTPS MITM for read-only enforcement in limited mode (default: false). |
| `network_proxy.mitm.inspect` | boolean | Enable body inspection in MITM mode (default: false). |
| `network_proxy.mitm.max_body_bytes` | number | Max body bytes to buffer when inspection is enabled (default: 4096). |

View File

@@ -133,6 +133,10 @@ poll_interval_ms = 1000
# Allow localhost binds inside the sandbox (macOS only). Default: false
allow_local_binding = false
# Allow Unix socket paths inside the sandbox (macOS only). Default: []
# Common values:
# - "$SSH_AUTH_SOCK" (recommended) or "${SSH_AUTH_SOCK}"
# - "ssh-agent" (alias: "ssh_auth_sock", "ssh_auth_socket")
# - an absolute socket path like "/private/tmp/..." (or a directory containing sockets)
allow_unix_sockets = []
# Optional domain allow/deny lists (denylist wins)
allowed_domains = []

View File

@@ -45,7 +45,7 @@ flowchart LR
The proxy reads `~/.codex/config.toml`:
- `[network_proxy]` for endpoints, mode, and toggles.
- `[network_proxy.policy]` for `allowed_domains` / `denied_domains`.
- `[network_proxy.policy]` for `allowed_domains` / `denied_domains` (and, on macOS, optional local IPC allowances).
- `[network_proxy.mitm]` for MITM CA paths and inspection settings.
Codex is the source of truth. Approval actions update the config and trigger a proxy reload.
@@ -56,6 +56,19 @@ Codex is the source of truth. Approval actions update the config and trigger a p
- **Limited mode:** only GET/HEAD/OPTIONS are permitted. HTTPS requires MITM to enforce method constraints; otherwise CONNECT is blocked with a clear reason.
- **Full mode:** all methods allowed; CONNECT tunneling is permitted without MITM.
## macOS Sandbox Integration (Seatbelt)
On macOS, Codex uses Seatbelt (`sandbox-exec`) for OS-level enforcement.
Key points:
- **Per-domain gating happens in the proxy**, not in Seatbelt: Seatbelt network rules are intentionally limited to loopback proxy ports (e.g. `localhost:3128` / `localhost:8081`) so all outbound traffic is forced through the proxy, which then applies the allow/deny policy and prompts.
- **Local IPC is deny-by-default** when proxy-restricted network access is active. Some tools rely on Unix domain sockets (e.g. the SSH agent). These are blocked unless explicitly allowed via:
- `network_proxy.policy.allow_unix_sockets` (absolute socket paths, `$SSH_AUTH_SOCK`, or the `ssh-agent` preset), and/or
- `network_proxy.policy.allow_local_binding` (if you need to bind/listen on localhost ports).
When approvals are enabled, Codex can preflight commands that appear to require the SSH agent and prompt to allow the SSH agent socket before running.
## Logging and Auditability
The proxy logs:

View File

@@ -33,6 +33,10 @@ poll_interval_ms = 1000
[network_proxy.policy]
allowed_domains = ["example.com", "*.github.com"]
denied_domains = ["metadata.google.internal", "169.254.*"]
# macOS only: allow specific local IPC when proxy-restricted.
allow_local_binding = false
# Example: allow SSH agent socket for git/ssh.
allow_unix_sockets = ["$SSH_AUTH_SOCK"]
[network_proxy.mitm]
enabled = false

View File

@@ -80,6 +80,15 @@ Network access is controlled through a proxy server running outside the sandbox:
On macOS, `[network_proxy.policy]` can also allow localhost binding or Unix socket paths when proxy-restricted network access is active. These settings influence the Seatbelt profile.
Unix sockets are deny-by-default. If you run tools that rely on local IPC (most commonly the SSH agent via `SSH_AUTH_SOCK`), you can allow them via:
```toml
[network_proxy.policy]
allow_unix_sockets = ["$SSH_AUTH_SOCK"]
```
When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (for example `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH). “Allow always” records `$SSH_AUTH_SOCK`; “Allow for session” records the resolved socket path and is removed when Codex exits.
When MITM is enabled in the proxy config, Codex injects common CA environment variables (for example `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`, `PIP_CERT`, and `NPM_CONFIG_CAFILE`) pointing at the proxy CA cert to reduce pertool configuration.
### Sandbox mechanics by platform