mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:*"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 per‑tool configuration.
|
||||
|
||||
### Sandbox mechanics by platform
|
||||
|
||||
Reference in New Issue
Block a user