This commit is contained in:
celia-oai
2026-02-18 22:28:46 -08:00
parent 79aee8f0da
commit 8be9296b05
11 changed files with 358 additions and 23 deletions
+1
View File
@@ -225,6 +225,7 @@ pub async fn process_exec_tool_call(
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap,
windows_sandbox_level,
macos_seatbelt_profile_extensions: None,
})
.map_err(CodexErr::from)?;
+81 -2
View File
@@ -19,7 +19,9 @@ use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt::create_seatbelt_command_args;
use crate::seatbelt::create_seatbelt_command_args_with_extensions;
#[cfg(target_os = "macos")]
pub(crate) use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -27,6 +29,8 @@ use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
pub use codex_protocol::models::SandboxPermissions;
#[cfg(not(target_os = "macos"))]
pub(crate) type MacOsSeatbeltProfileExtensions = ();
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -72,6 +76,7 @@ pub(crate) struct SandboxTransformRequest<'a> {
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
pub use_linux_sandbox_bwrap: bool,
pub windows_sandbox_level: WindowsSandboxLevel,
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
}
pub enum SandboxPreference {
@@ -147,6 +152,7 @@ impl SandboxManager {
codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
windows_sandbox_level,
macos_seatbelt_profile_extensions,
} = request;
let mut env = spec.env;
if !policy.has_full_network_access() {
@@ -172,12 +178,13 @@ impl SandboxManager {
let zsh_exec_bridge_allowed_unix_sockets = zsh_exec_bridge_wrapper_socket
.as_ref()
.map_or_else(Vec::new, |path| vec![path.clone()]);
let mut args = create_seatbelt_command_args(
let mut args = create_seatbelt_command_args_with_extensions(
command.clone(),
policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
macos_seatbelt_profile_extensions,
&zsh_exec_bridge_allowed_unix_sockets,
);
let mut full_command = Vec::with_capacity(1 + args.len());
@@ -249,12 +256,22 @@ pub async fn execute_env(
#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
use super::CommandSpec;
use super::SandboxManager;
#[cfg(target_os = "macos")]
use super::SandboxTransformRequest;
#[cfg(target_os = "macos")]
use crate::exec::ExecExpiration;
use crate::exec::SandboxType;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
use codex_protocol::config_types::WindowsSandboxLevel;
#[cfg(target_os = "macos")]
use codex_protocol::models::SandboxPermissions;
use pretty_assertions::assert_eq;
#[cfg(target_os = "macos")]
use std::collections::HashMap;
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
@@ -280,4 +297,66 @@ mod tests {
);
assert_eq!(sandbox, expected);
}
#[cfg(target_os = "macos")]
#[test]
fn transform_applies_macos_seatbelt_profile_extensions_when_present() {
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use crate::seatbelt_permissions::MacOsAutomationPermission;
use crate::seatbelt_permissions::MacOsPreferencesPermission;
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
let manager = SandboxManager::new();
let tempdir = tempfile::tempdir().expect("tempdir");
let cwd = tempdir.path().to_path_buf();
let spec = CommandSpec {
program: "/bin/echo".to_string(),
args: vec!["ok".to_string()],
cwd: cwd.clone(),
env: HashMap::new(),
expiration: ExecExpiration::DefaultTimeout,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
};
let extensions = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: false,
};
let transformed = manager
.transform(SandboxTransformRequest {
spec,
policy: &SandboxPolicy::new_read_only_policy(),
sandbox: SandboxType::MacosSeatbelt,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: &cwd,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
macos_seatbelt_profile_extensions: Some(&extensions),
})
.expect("transform");
assert_eq!(
transformed.command.first(),
Some(&MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string())
);
let policy_arg_idx = transformed
.command
.iter()
.position(|arg| arg == "-p")
.expect("contains -p policy");
let policy = transformed
.command
.get(policy_arg_idx + 1)
.expect("policy after -p");
assert!(policy.contains("(allow user-preference-write)"));
assert!(policy.contains("com.apple.Notes"));
assert!(policy.contains("com.apple.axserver"));
}
}
+171
View File
@@ -53,6 +53,85 @@ impl MacOsSeatbeltProfileExtensions {
}
}
pub(crate) fn merge_macos_seatbelt_profile_extensions(
base: Option<&MacOsSeatbeltProfileExtensions>,
extension: Option<&MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
match (base, extension) {
(None, None) => None,
(Some(base), None) => Some(base.clone().normalized()),
(None, Some(extension)) => Some(extension.clone().normalized()),
(Some(base), Some(extension)) => {
let base = base.normalized();
let extension = extension.normalized();
Some(
MacOsSeatbeltProfileExtensions {
macos_preferences: merge_macos_preferences_permission(
&base.macos_preferences,
&extension.macos_preferences,
),
macos_automation: merge_macos_automation_permission(
&base.macos_automation,
&extension.macos_automation,
),
macos_accessibility: base.macos_accessibility && extension.macos_accessibility,
macos_calendar: base.macos_calendar && extension.macos_calendar,
}
.normalized(),
)
}
}
}
fn merge_macos_preferences_permission(
base: &MacOsPreferencesPermission,
extension: &MacOsPreferencesPermission,
) -> MacOsPreferencesPermission {
fn rank(permission: &MacOsPreferencesPermission) -> u8 {
match permission {
MacOsPreferencesPermission::None => 0,
MacOsPreferencesPermission::ReadOnly => 1,
MacOsPreferencesPermission::ReadWrite => 2,
}
}
if rank(extension) < rank(base) {
extension.clone()
} else {
base.clone()
}
}
fn merge_macos_automation_permission(
base: &MacOsAutomationPermission,
extension: &MacOsAutomationPermission,
) -> MacOsAutomationPermission {
match (base, extension) {
(MacOsAutomationPermission::None, _) | (_, MacOsAutomationPermission::None) => {
MacOsAutomationPermission::None
}
(MacOsAutomationPermission::All, other) | (other, MacOsAutomationPermission::All) => {
other.clone()
}
(
MacOsAutomationPermission::BundleIds(base_ids),
MacOsAutomationPermission::BundleIds(extension_ids),
) => {
let base_ids = base_ids.iter().cloned().collect::<BTreeSet<_>>();
let extension_ids = extension_ids.iter().cloned().collect::<BTreeSet<_>>();
let intersection = base_ids
.intersection(&extension_ids)
.cloned()
.collect::<Vec<_>>();
if intersection.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(intersection)
}
}
}
}
pub(crate) fn build_seatbelt_extensions(
extensions: &MacOsSeatbeltProfileExtensions,
) -> SeatbeltExtensionPolicy {
@@ -166,6 +245,7 @@ mod tests {
use super::MacOsPreferencesPermission;
use super::MacOsSeatbeltProfileExtensions;
use super::build_seatbelt_extensions;
use super::merge_macos_seatbelt_profile_extensions;
#[test]
fn preferences_read_only_emits_read_clauses_only() {
@@ -245,4 +325,95 @@ mod tests {
assert!(policy.policy.contains("(allow user-preference-read)"));
assert!(!policy.policy.contains("(allow user-preference-write)"));
}
#[test]
fn merge_extensions_intersects_permissions() {
let base = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
"com.apple.Calendar".to_string(),
]),
macos_accessibility: false,
macos_calendar: true,
};
let extension = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Reminders".to_string(),
"com.apple.Calendar".to_string(),
]),
macos_accessibility: true,
macos_calendar: false,
};
let merged =
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
assert_eq!(
merged.macos_preferences,
MacOsPreferencesPermission::ReadOnly
);
assert_eq!(
merged.macos_automation,
MacOsAutomationPermission::BundleIds(vec!["com.apple.Calendar".to_string(),])
);
assert!(!merged.macos_accessibility);
assert!(!merged.macos_calendar);
}
#[test]
fn merge_extensions_all_intersects_to_other_side() {
let base = MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::All,
..Default::default()
};
let extension = MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
..Default::default()
};
let merged =
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
assert_eq!(
merged.macos_automation,
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string(),])
);
}
#[test]
fn merge_extensions_none_intersects_to_none() {
let base = MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::All,
..Default::default()
};
let extension = MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::None,
..Default::default()
};
let merged =
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
assert_eq!(merged.macos_automation, MacOsAutomationPermission::None);
}
#[test]
fn merge_extensions_normalizes_single_source() {
let extension = MacOsSeatbeltProfileExtensions {
macos_automation: MacOsAutomationPermission::BundleIds(vec![
" com.apple.Notes ".to_string(),
"com.apple.Notes".to_string(),
]),
..Default::default()
};
let merged =
merge_macos_seatbelt_profile_extensions(None, Some(&extension)).expect("merged");
assert_eq!(
merged.macos_automation,
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()])
);
}
}
+43 -10
View File
@@ -3,14 +3,13 @@ use std::path::Path;
use std::path::PathBuf;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::SandboxPolicy;
use dunce::canonicalize as canonicalize_path;
use crate::config::Permissions;
use crate::path_utils::normalize_for_path_comparison;
use crate::skills::SkillLoadOutcome;
/// Resolves the sandbox policy extension contributed by the first matching
/// Resolves the full permissions extension contributed by the first matching
/// skill for a command invocation.
///
/// Assumptions:
@@ -23,13 +22,30 @@ use crate::skills::SkillLoadOutcome;
/// Returns `None` when `shell_zsh_fork` is disabled, command shell is not
/// `zsh`, or when no enabled skill with permissions matches an eligible
/// command action executable.
pub(crate) fn resolve_skill_sandbox_extension_for_command(
pub(crate) fn resolve_skill_permissions_for_command(
skills_outcome: &SkillLoadOutcome,
shell_zsh_fork_enabled: bool,
command: &[String],
command_cwd: &Path,
command_actions: &[ParsedCommand],
) -> Option<SandboxPolicy> {
) -> Option<Permissions> {
resolve_skill_permissions_ref(
skills_outcome,
shell_zsh_fork_enabled,
command,
command_cwd,
command_actions,
)
.cloned()
}
fn resolve_skill_permissions_ref<'a>(
skills_outcome: &'a SkillLoadOutcome,
shell_zsh_fork_enabled: bool,
command: &[String],
command_cwd: &Path,
command_actions: &[ParsedCommand],
) -> Option<&'a Permissions> {
if !shell_zsh_fork_enabled {
return None;
}
@@ -48,7 +64,7 @@ pub(crate) fn resolve_skill_sandbox_extension_for_command(
continue;
};
if let Some(permissions) = match_skill_for_candidate(skills_outcome, &action_candidate) {
return Some(permissions.sandbox_policy.get().clone());
return Some(permissions);
}
}
@@ -74,7 +90,7 @@ fn command_action_candidate_path(
| ParsedCommand::Search { .. } => None,
}?;
let action_path = if action_path.is_absolute() {
action_path.to_path_buf()
action_path
} else {
command_cwd.join(action_path)
};
@@ -143,7 +159,7 @@ fn normalize_lexically(path: &Path) -> PathBuf {
#[cfg(test)]
mod tests {
use super::resolve_skill_sandbox_extension_for_command;
use super::resolve_skill_permissions_for_command;
use crate::config::Constrained;
use crate::config::Permissions;
use crate::config::types::ShellEnvironmentPolicy;
@@ -193,6 +209,23 @@ mod tests {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn resolve_skill_sandbox_extension_for_command(
skills_outcome: &SkillLoadOutcome,
shell_zsh_fork_enabled: bool,
command: &[String],
command_cwd: &Path,
command_actions: &[ParsedCommand],
) -> Option<SandboxPolicy> {
resolve_skill_permissions_for_command(
skills_outcome,
shell_zsh_fork_enabled,
command,
command_cwd,
command_actions,
)
.map(|permissions| permissions.sandbox_policy.get().clone())
}
#[test]
fn resolves_policy_for_zsh_executable_inside_skill_scripts_directory() {
let tempdir = tempfile::tempdir().expect("tempdir");
@@ -256,7 +289,7 @@ mod tests {
};
let outcome = outcome_with_skills(vec![skill_with_policy(
canonical(&skill_path),
skill_policy.clone(),
skill_policy,
)]);
let command = vec![
"/bin/bash".to_string(),
@@ -297,7 +330,7 @@ mod tests {
};
let outcome = outcome_with_skills(vec![skill_with_policy(
canonical(&skill_path),
skill_policy.clone(),
skill_policy,
)]);
let command = vec![
"/bin/zsh".to_string(),
@@ -396,7 +429,7 @@ mod tests {
exclude_slash_tmp: false,
};
let outcome = outcome_with_skills(vec![
skill_with_policy(canonical(&parent_skill_path), parent_policy.clone()),
skill_with_policy(canonical(&parent_skill_path), parent_policy),
skill_with_policy(canonical(&nested_skill_path), nested_policy.clone()),
]);
let command = vec![
+1 -1
View File
@@ -9,7 +9,7 @@ pub mod remote;
pub mod render;
pub mod system;
pub(crate) use command_policy::resolve_skill_sandbox_extension_for_command;
pub(crate) use command_policy::resolve_skill_permissions_for_command;
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
pub(crate) use injection::SkillInjections;
+39 -6
View File
@@ -15,8 +15,12 @@ use crate::is_safe_command::is_known_safe_command;
use crate::parse_command::parse_command;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::extend_sandbox_policy;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::merge_macos_seatbelt_profile_extensions;
use crate::shell::Shell;
use crate::skills::resolve_skill_sandbox_extension_for_command;
use crate::skills::resolve_skill_permissions_for_command;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -30,6 +34,8 @@ use crate::tools::registry::ToolKind;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::sandboxing::ToolCtx;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
pub struct ShellHandler;
@@ -308,19 +314,29 @@ impl ShellHandler {
.await;
let shell_zsh_fork_enabled = session.features().enabled(Feature::ShellZshFork);
let command_actions = parse_command(&exec_params.command);
let effective_sandbox_policy = resolve_skill_sandbox_extension_for_command(
let skill_permissions = resolve_skill_permissions_for_command(
&skills_outcome,
shell_zsh_fork_enabled,
&exec_params.command,
&exec_params.cwd,
&command_actions,
)
.map_or_else(
);
let effective_sandbox_policy = skill_permissions.as_ref().map_or_else(
|| turn.sandbox_policy.clone(),
|skill_sandbox_policy| {
extend_sandbox_policy(&turn.sandbox_policy, &skill_sandbox_policy)
|skill_permissions| {
extend_sandbox_policy(&turn.sandbox_policy, skill_permissions.sandbox_policy.get())
},
);
let effective_macos_seatbelt_profile_extensions =
merge_turn_and_skill_macos_seatbelt_profile_extensions(
turn.config
.permissions
.macos_seatbelt_profile_extensions
.as_ref(),
skill_permissions
.as_ref()
.and_then(|permissions| permissions.macos_seatbelt_profile_extensions.as_ref()),
);
let exec_approval_requirement = session
.services
@@ -343,6 +359,7 @@ impl ShellHandler {
network: exec_params.network.clone(),
sandbox_permissions: exec_params.sandbox_permissions,
justification: exec_params.justification.clone(),
macos_seatbelt_profile_extensions: effective_macos_seatbelt_profile_extensions,
exec_approval_requirement,
};
let mut orchestrator = ToolOrchestrator::new();
@@ -374,6 +391,22 @@ impl ShellHandler {
}
}
#[cfg(target_os = "macos")]
fn merge_turn_and_skill_macos_seatbelt_profile_extensions(
turn_extensions: Option<&MacOsSeatbeltProfileExtensions>,
skill_extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
merge_macos_seatbelt_profile_extensions(turn_extensions, skill_extensions)
}
#[cfg(not(target_os = "macos"))]
fn merge_turn_and_skill_macos_seatbelt_profile_extensions(
_turn_extensions: Option<&MacOsSeatbeltProfileExtensions>,
_skill_extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
None
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
+1
View File
@@ -617,6 +617,7 @@ impl JsReplManager {
.features
.enabled(crate::features::Feature::UseLinuxSandboxBwrap),
windows_sandbox_level: turn.windows_sandbox_level,
macos_seatbelt_profile_extensions: None,
})
.map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?;
@@ -151,7 +151,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
) -> Result<ExecToolCallOutput, ToolError> {
let spec = Self::build_command_spec(req)?;
let env = attempt
.env_for(spec, None)
.env_for(spec, None, None)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
+15 -2
View File
@@ -10,6 +10,8 @@ use crate::features::Feature;
use crate::powershell::prefix_powershell_script_with_utf8;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
@@ -31,6 +33,8 @@ use codex_network_proxy::NetworkProxy;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::path::PathBuf;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
#[derive(Clone, Debug)]
pub struct ShellRequest {
@@ -42,6 +46,7 @@ pub struct ShellRequest {
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
pub macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
pub exec_approval_requirement: ExecApprovalRequirement,
}
@@ -203,7 +208,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.justification.clone(),
)?;
let env = attempt
.env_for(spec, req.network.as_ref())
.env_for(
spec,
req.network.as_ref(),
req.macos_seatbelt_profile_extensions.as_ref(),
)
.map_err(|err| ToolError::Codex(err.into()))?;
return ctx
.session
@@ -222,7 +231,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.justification.clone(),
)?;
let mut env = attempt
.env_for(spec, req.network.as_ref())
.env_for(
spec,
req.network.as_ref(),
req.macos_seatbelt_profile_extensions.as_ref(),
)
.map_err(|err| ToolError::Codex(err.into()))?;
env.network_attempt_id = ctx.network_attempt_id.clone();
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
@@ -200,7 +200,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.env_for(spec, req.network.as_ref(), None)
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty)
+4
View File
@@ -293,6 +293,9 @@ impl<'a> SandboxAttempt<'a> {
&self,
spec: CommandSpec,
network: Option<&NetworkProxy>,
macos_seatbelt_profile_extensions: Option<
&crate::sandboxing::MacOsSeatbeltProfileExtensions,
>,
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
self.manager
.transform(crate::sandboxing::SandboxTransformRequest {
@@ -305,6 +308,7 @@ impl<'a> SandboxAttempt<'a> {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
windows_sandbox_level: self.windows_sandbox_level,
macos_seatbelt_profile_extensions,
})
}
}