feat: include sandbox config with escalation request

This commit is contained in:
Michael Bolin
2026-02-26 09:23:05 -08:00
parent b65205fb3d
commit 88835571fd
19 changed files with 1123 additions and 197 deletions
+1 -4
View File
@@ -49,8 +49,6 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
use crate::windows_sandbox::WindowsSandboxLevelExt;
@@ -66,6 +64,7 @@ use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_rmcp_client::OAuthCredentialsStoreMode;
@@ -82,8 +81,6 @@ use std::path::Path;
use std::path::PathBuf;
#[cfg(test)]
use tempfile::tempdir;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
use crate::config::permissions::network_proxy_config_from_permissions;
use crate::config::profile::ConfigProfile;
+2
View File
@@ -219,6 +219,8 @@ pub async fn process_exec_tool_call(
enforce_managed_network,
network: network.as_ref(),
sandbox_policy_cwd: sandbox_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap,
windows_sandbox_level,
+9 -2
View File
@@ -17,7 +17,7 @@ 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")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -25,6 +25,8 @@ use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::ReadOnlyAccess;
@@ -73,6 +75,8 @@ pub(crate) struct SandboxTransformRequest<'a> {
// to make shared ownership explicit across runtime/sandbox plumbing.
pub network: Option<&'a NetworkProxy>,
pub sandbox_policy_cwd: &'a Path,
#[cfg(target_os = "macos")]
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
pub use_linux_sandbox_bwrap: bool,
pub windows_sandbox_level: WindowsSandboxLevel,
@@ -342,6 +346,8 @@ impl SandboxManager {
enforce_managed_network,
network,
sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
windows_sandbox_level,
@@ -370,12 +376,13 @@ impl SandboxManager {
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args(
let mut args = create_seatbelt_command_args_with_extensions(
command.clone(),
&effective_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
macos_seatbelt_profile_extensions,
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
+17 -41
View File
@@ -3,34 +3,9 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MacOsPreferencesPermission {
// IMPORTANT: ReadOnly needs to be the default because it's the security-sensitive default.
// it's important for allowing cf prefs to work.
#[default]
ReadOnly,
ReadWrite,
None,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MacOsAutomationPermission {
#[default]
None,
All,
BundleIds(Vec<String>),
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MacOsSeatbeltProfileExtensions {
pub macos_preferences: MacOsPreferencesPermission,
pub macos_automation: MacOsAutomationPermission,
pub macos_accessibility: bool,
pub macos_calendar: bool,
}
pub use codex_protocol::models::MacOsAutomationPermission;
pub use codex_protocol::models::MacOsPreferencesPermission;
pub use codex_protocol::models::MacOsSeatbeltProfileExtensions;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SeatbeltExtensionPolicy {
@@ -38,25 +13,26 @@ pub(crate) struct SeatbeltExtensionPolicy {
pub(crate) dir_params: Vec<(String, PathBuf)>,
}
impl MacOsSeatbeltProfileExtensions {
pub fn normalized(&self) -> Self {
let mut normalized = self.clone();
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &self.macos_automation {
let bundle_ids = normalize_bundle_ids(bundle_ids);
normalized.macos_automation = if bundle_ids.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(bundle_ids)
};
}
normalized
fn normalized_extensions(
extensions: &MacOsSeatbeltProfileExtensions,
) -> MacOsSeatbeltProfileExtensions {
let mut normalized = extensions.clone();
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &extensions.macos_automation {
let bundle_ids = normalize_bundle_ids(bundle_ids);
normalized.macos_automation = if bundle_ids.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(bundle_ids)
};
}
normalized
}
pub(crate) fn build_seatbelt_extensions(
extensions: &MacOsSeatbeltProfileExtensions,
) -> SeatbeltExtensionPolicy {
let extensions = extensions.normalized();
let extensions = normalized_extensions(extensions);
let mut clauses = Vec::new();
match extensions.macos_preferences {
+1 -4
View File
@@ -8,6 +8,7 @@ use codex_protocol::models::MacOsAutomationValue;
use codex_protocol::models::MacOsPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPreferencesValue;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
@@ -20,10 +21,6 @@ use crate::config::types::ShellEnvironmentPolicy;
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
pub(crate) fn compile_permission_profile(
skill_dir: &Path,
+2
View File
@@ -613,6 +613,8 @@ impl JsReplManager {
enforce_managed_network: has_managed_network_requirements,
network: None,
sandbox_policy_cwd: &turn.cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap: turn
.features
@@ -12,12 +12,14 @@ use crate::skills::SkillMetadata;
use crate::tools::runtimes::ExecveSessionApproval;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use codex_execpolicy::Decision;
use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::NetworkPolicyRuleAction;
@@ -26,11 +28,14 @@ use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_shell_escalation::EscalateAction;
use codex_shell_escalation::EscalateServer;
use codex_shell_escalation::EscalationDecision;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::EscalationPolicy;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::Permissions as EscalatedPermissions;
use codex_shell_escalation::PreparedExec;
use codex_shell_escalation::ShellCommandExecutor;
use codex_shell_escalation::Stopwatch;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -94,6 +99,10 @@ pub(super) async fn try_run_zsh_fork(
let exec_policy = Arc::new(RwLock::new(
ctx.session.services.exec_policy.current().as_ref().clone(),
));
let escalation_permissions = CoreShellActionProvider::shell_request_escalation_permissions(
&sandbox_policy,
req.additional_permissions.as_ref(),
);
let command_executor = CoreShellCommandExecutor {
command,
cwd: sandbox_cwd,
@@ -105,6 +114,9 @@ pub(super) async fn try_run_zsh_fork(
sandbox_permissions,
justification,
arg0,
sandbox_policy_cwd: ctx.turn.cwd.clone(),
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
};
let main_execve_wrapper_exe = ctx
.session
@@ -136,6 +148,8 @@ pub(super) async fn try_run_zsh_fork(
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: attempt.policy.clone(),
sandbox_permissions: req.sandbox_permissions,
prompt_permissions: req.additional_permissions.clone(),
escalation_permissions,
stopwatch: stopwatch.clone(),
};
@@ -146,7 +160,7 @@ pub(super) async fn try_run_zsh_fork(
);
let exec_result = escalate_server
.exec(exec_params, cancel_token, &command_executor)
.exec(exec_params, cancel_token, Arc::new(command_executor))
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
@@ -161,6 +175,8 @@ struct CoreShellActionProvider {
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
sandbox_permissions: SandboxPermissions,
prompt_permissions: Option<PermissionProfile>,
escalation_permissions: Option<EscalationPermissions>,
stopwatch: Stopwatch,
}
@@ -182,6 +198,60 @@ impl CoreShellActionProvider {
})
}
fn shell_request_escalation_permissions(
sandbox_policy: &SandboxPolicy,
additional_permissions: Option<&PermissionProfile>,
) -> Option<EscalationPermissions> {
additional_permissions.map(|_| {
// Shell request additional permissions were already normalized and
// merged into the first-attempt sandbox policy.
EscalationPermissions::Permissions(EscalatedPermissions {
sandbox_policy: sandbox_policy.clone(),
macos_seatbelt_profile_extensions: None,
})
})
}
fn turn_escalation_permissions(&self) -> EscalationPermissions {
EscalationPermissions::Permissions(EscalatedPermissions {
sandbox_policy: self.sandbox_policy.clone(),
macos_seatbelt_profile_extensions: self
.turn
.config
.permissions
.macos_seatbelt_profile_extensions
.clone(),
})
}
fn skill_escalation_permissions(skill: &SkillMetadata) -> Option<EscalationPermissions> {
skill
.permissions
.as_ref()
.map(|permissions| {
EscalationPermissions::Permissions(EscalatedPermissions {
sandbox_policy: permissions.sandbox_policy.get().clone(),
macos_seatbelt_profile_extensions: permissions
.macos_seatbelt_profile_extensions
.clone(),
})
})
.or_else(|| {
skill
.permission_profile
.clone()
.map(EscalationPermissions::PermissionProfile)
})
}
fn effective_skill_escalation_permissions(
&self,
skill: &SkillMetadata,
) -> EscalationPermissions {
Self::skill_escalation_permissions(skill)
.unwrap_or_else(|| self.turn_escalation_permissions())
}
async fn prompt(
&self,
program: &AbsolutePathBuf,
@@ -265,22 +335,21 @@ impl CoreShellActionProvider {
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
additional_permissions: Option<PermissionProfile>,
prompt_permissions: Option<PermissionProfile>,
escalation_permissions: Option<EscalationPermissions>,
decision_source: DecisionSource,
) -> anyhow::Result<EscalateAction> {
) -> anyhow::Result<EscalationDecision> {
let action = match decision {
Decision::Forbidden => EscalateAction::Deny {
reason: Some("Execution forbidden by policy".to_string()),
},
Decision::Forbidden => {
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
}
Decision::Prompt => {
if matches!(
self.approval_policy,
AskForApproval::Never
| AskForApproval::Reject(RejectConfig { rules: true, .. })
) {
EscalateAction::Deny {
reason: Some("Execution forbidden by policy".to_string()),
}
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
} else {
match self
.prompt(
@@ -288,7 +357,7 @@ impl CoreShellActionProvider {
argv,
workdir,
&self.stopwatch,
additional_permissions,
prompt_permissions,
&decision_source,
)
.await?
@@ -296,9 +365,9 @@ impl CoreShellActionProvider {
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
ReviewDecision::ApprovedForSession => {
@@ -323,9 +392,9 @@ impl CoreShellActionProvider {
}
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
ReviewDecision::NetworkPolicyAmendment {
@@ -333,29 +402,29 @@ impl CoreShellActionProvider {
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
NetworkPolicyRuleAction::Deny => EscalateAction::Deny {
reason: Some("User denied execution".to_string()),
},
},
ReviewDecision::Denied => EscalateAction::Deny {
reason: Some("User denied execution".to_string()),
},
ReviewDecision::Abort => EscalateAction::Deny {
reason: Some("User cancelled execution".to_string()),
NetworkPolicyRuleAction::Deny => {
EscalationDecision::deny(Some("User denied execution".to_string()))
}
},
ReviewDecision::Denied => {
EscalationDecision::deny(Some("User denied execution".to_string()))
}
ReviewDecision::Abort => {
EscalationDecision::deny(Some("User cancelled execution".to_string()))
}
}
}
}
Decision::Allow => {
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
};
@@ -373,7 +442,7 @@ impl EscalationPolicy for CoreShellActionProvider {
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction> {
) -> anyhow::Result<EscalationDecision> {
tracing::debug!(
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
);
@@ -394,15 +463,12 @@ impl EscalationPolicy for CoreShellActionProvider {
tracing::debug!(
"Found session approval for {program:?}, allowing execution without further checks"
);
// TODO(mbolin): We need to include the permissions with the
// escalation decision so it can be run with the appropriate
// permissions.
let _permissions = approval
let permissions = approval
.skill
.as_ref()
.and_then(|s| s.permission_profile.clone());
.map(|skill| self.effective_skill_escalation_permissions(skill));
return Ok(EscalateAction::Escalate);
return Ok(EscalationDecision::escalate(permissions));
}
// In the usual case, the execve wrapper reports the command being
@@ -424,6 +490,7 @@ impl EscalationPolicy for CoreShellActionProvider {
argv,
workdir,
skill.permission_profile.clone(),
Some(self.effective_skill_escalation_permissions(&skill)),
decision_source,
)
.await;
@@ -470,7 +537,8 @@ impl EscalationPolicy for CoreShellActionProvider {
program,
argv,
workdir,
None,
self.prompt_permissions.clone(),
self.escalation_permissions.clone(),
decision_source,
)
.await
@@ -488,6 +556,9 @@ struct CoreShellCommandExecutor {
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
arg0: Option<String>,
sandbox_policy_cwd: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
use_linux_sandbox_bwrap: bool,
}
#[async_trait::async_trait]
@@ -533,6 +604,118 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
timed_out: result.timed_out,
})
}
async fn prepare_escalated_exec(
&self,
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
permissions: Option<EscalationPermissions>,
) -> anyhow::Result<PreparedExec> {
let command = join_program_and_argv(program, argv);
let Some(first_arg) = argv.first() else {
return Err(anyhow::anyhow!(
"intercepted exec request must contain argv[0]"
));
};
let Some(permissions) = permissions else {
return Ok(PreparedExec {
command,
cwd: workdir.to_path_buf(),
env,
arg0: Some(first_arg.clone()),
});
};
let prepared = match permissions {
EscalationPermissions::PermissionProfile(permission_profile) => self
.prepare_sandboxed_exec(
command,
workdir,
env,
&self.sandbox_policy,
Some(permission_profile),
None,
)?,
EscalationPermissions::Permissions(permissions) => self.prepare_sandboxed_exec(
command,
workdir,
env,
&permissions.sandbox_policy,
None,
permissions.macos_seatbelt_profile_extensions.as_ref(),
)?,
};
Ok(prepared)
}
}
impl CoreShellCommandExecutor {
fn prepare_sandboxed_exec(
&self,
command: Vec<String>,
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
sandbox_policy: &SandboxPolicy,
additional_permissions: Option<PermissionProfile>,
#[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<
&MacOsSeatbeltProfileExtensions,
>,
#[cfg(not(target_os = "macos"))] _macos_seatbelt_profile_extensions: Option<
&MacOsSeatbeltProfileExtensions,
>,
) -> anyhow::Result<PreparedExec> {
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let sandbox = sandbox_manager.select_initial(
sandbox_policy,
SandboxablePreference::Auto,
self.windows_sandbox_level,
self.network.is_some(),
);
let mut exec_request =
sandbox_manager.transform(crate::sandboxing::SandboxTransformRequest {
spec: crate::sandboxing::CommandSpec {
program: program.clone(),
args: args.to_vec(),
cwd: workdir.to_path_buf(),
env,
expiration: ExecExpiration::DefaultTimeout,
sandbox_permissions: if additional_permissions.is_some() {
SandboxPermissions::WithAdditionalPermissions
} else {
SandboxPermissions::UseDefault
},
additional_permissions,
justification: self.justification.clone(),
},
policy: sandbox_policy,
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
windows_sandbox_level: self.windows_sandbox_level,
})?;
if let Some(network) = exec_request.network.as_ref() {
network.apply_to_env(&mut exec_request.env);
}
Ok(PreparedExec {
command: exec_request.command,
cwd: exec_request.cwd,
env: exec_request.env,
arg0: exec_request.arg0,
})
}
}
#[derive(Debug, Eq, PartialEq)]
@@ -601,14 +784,46 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<Stri
#[cfg(test)]
mod tests {
use super::CoreShellActionProvider;
#[cfg(target_os = "macos")]
use super::CoreShellCommandExecutor;
use super::ParsedShellCommand;
use super::extract_shell_script;
use super::join_program_and_argv;
use super::map_exec_result;
#[cfg(target_os = "macos")]
use crate::config::Constrained;
#[cfg(target_os = "macos")]
use crate::config::Permissions;
#[cfg(target_os = "macos")]
use crate::config::types::ShellEnvironmentPolicy;
use crate::exec::SandboxType;
#[cfg(target_os = "macos")]
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::sandboxing::SandboxPermissions;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsPreferencesPermission;
#[cfg(target_os = "macos")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::Permissions as EscalatedPermissions;
#[cfg(target_os = "macos")]
use codex_shell_escalation::ShellCommandExecutor;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[cfg(target_os = "macos")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
#[test]
@@ -718,4 +933,99 @@ mod tests {
assert_eq!(out.stderr.text, "err");
assert_eq!(out.aggregated_output.text, "outerr");
}
#[test]
fn shell_request_escalation_permissions_use_concrete_policy() {
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![PathBuf::from("./output")]),
}),
..Default::default()
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![
AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap(),
],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
assert_eq!(
CoreShellActionProvider::shell_request_escalation_permissions(
&sandbox_policy,
Some(&requested_permissions),
),
Some(EscalationPermissions::Permissions(EscalatedPermissions {
sandbox_policy,
macos_seatbelt_profile_extensions: None,
})),
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn prepare_escalated_exec_preserves_macos_seatbelt_extensions() {
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
let executor = CoreShellCommandExecutor {
command: vec!["echo".to_string(), "ok".to_string()],
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
sandbox_policy_cwd: cwd.to_path_buf(),
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
};
let permissions = Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
..Default::default()
}),
};
let prepared = executor
.prepare_escalated_exec(
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
&["echo".to_string(), "ok".to_string()],
&cwd,
HashMap::new(),
Some(EscalationPermissions::Permissions(EscalatedPermissions {
sandbox_policy: permissions.sandbox_policy.get().clone(),
macos_seatbelt_profile_extensions: permissions
.macos_seatbelt_profile_extensions
.clone(),
})),
)
.await
.unwrap();
assert_eq!(
prepared.command.first().map(String::as_str),
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
);
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
assert!(
prepared
.command
.get(2)
.is_some_and(|policy| policy.contains("(allow user-preference-write)")),
"expected seatbelt policy to include macOS extension profile: {:?}",
prepared.command
);
}
}
+2
View File
@@ -340,6 +340,8 @@ impl<'a> SandboxAttempt<'a> {
enforce_managed_network: self.enforce_managed_network,
network,
sandbox_policy_cwd: self.sandbox_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
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,
+1
View File
@@ -114,6 +114,7 @@ mod seatbelt;
mod shell_command;
mod shell_serialization;
mod shell_snapshot;
mod skill_approval;
mod skills;
mod sqlite_state;
mod stream_error_allows_next_turn;
+473 -90
View File
@@ -2,13 +2,16 @@
#![cfg(unix)]
use anyhow::Result;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::mount_function_call_agent_response;
@@ -65,6 +68,24 @@ async fn submit_turn_with_policies(
}
fn write_skill_with_shell_script(home: &Path, name: &str, script_name: &str) -> Result<PathBuf> {
write_skill_with_shell_script_contents(
home,
name,
script_name,
r#"#!/bin/sh
echo 'zsh-fork-stdout'
echo 'zsh-fork-stderr' >&2
"#,
)
}
#[cfg(unix)]
fn write_skill_with_shell_script_contents(
home: &Path,
name: &str,
script_name: &str,
script_contents: &str,
) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let skill_dir = home.join("skills").join(name);
@@ -82,13 +103,7 @@ description: {name} skill
)?;
let script_path = scripts_dir.join(script_name);
fs::write(
&script_path,
r#"#!/bin/sh
echo 'zsh-fork-stdout'
echo 'zsh-fork-stderr' >&2
"#,
)?;
fs::write(&script_path, script_contents)?;
let mut permissions = fs::metadata(&script_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions)?;
@@ -129,34 +144,134 @@ fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool {
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
use codex_config::Constrained;
use codex_protocol::protocol::ReviewDecision;
#[derive(Clone)]
struct ZshForkRuntime {
zsh_path: PathBuf,
main_execve_wrapper_exe: PathBuf,
}
skip_if_no_network!(Ok(()));
impl ZshForkRuntime {
fn apply_to_config(
&self,
config: &mut Config,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
) {
use codex_config::Constrained;
config.features.enable(Feature::ShellTool);
config.features.enable(Feature::ShellZshFork);
config.zsh_path = Some(self.zsh_path.clone());
config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone());
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy);
}
}
fn restrictive_workspace_write_policy() -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
}
fn zsh_fork_runtime(test_name: &str) -> Result<Option<ZshForkRuntime>> {
let Some(zsh_path) = find_test_zsh_path()? else {
return Ok(());
return Ok(None);
};
if !supports_exec_wrapper_intercept(&zsh_path) {
eprintln!(
"skipping zsh-fork skill test: zsh does not support EXEC_WRAPPER intercepts ({})",
"skipping {test_name}: zsh does not support EXEC_WRAPPER intercepts ({})",
zsh_path.display()
);
return Ok(());
return Ok(None);
}
let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")
else {
eprintln!("skipping zsh-fork skill test: unable to resolve `codex-execve-wrapper` binary");
eprintln!("skipping {test_name}: unable to resolve `codex-execve-wrapper` binary");
return Ok(None);
};
Ok(Some(ZshForkRuntime {
zsh_path,
main_execve_wrapper_exe,
}))
}
async fn build_zsh_fork_test<F>(
server: &wiremock::MockServer,
runtime: ZshForkRuntime,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
pre_build_hook: F,
) -> Result<TestCodex>
where
F: FnOnce(&Path) + Send + 'static,
{
let mut builder = test_codex()
.with_pre_build_hook(pre_build_hook)
.with_config(move |config| {
runtime.apply_to_config(config, approval_policy, sandbox_policy);
});
builder.build(server).await
}
fn skill_script_command(test: &TestCodex, script_name: &str) -> Result<(String, String)> {
let script_path = fs::canonicalize(
test.codex_home_path()
.join("skills/mbolin-test-skill/scripts")
.join(script_name),
)?;
let script_path_str = script_path.to_string_lossy().into_owned();
let command = shlex::try_join([script_path_str.as_str()])?;
Ok((script_path_str, command))
}
async fn wait_for_exec_approval_request(test: &TestCodex) -> Option<ExecApprovalRequestEvent> {
wait_for_event_match(test.codex.as_ref(), |event| match event {
EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())),
EventMsg::TurnComplete(_) => Some(None),
_ => None,
})
.await
}
async fn wait_for_turn_complete(test: &TestCodex) {
wait_for_event(test.codex.as_ref(), |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
fn output_shows_sandbox_denial(output: &str) -> bool {
output.contains("Permission denied")
|| output.contains("Operation not permitted")
|| output.contains("Read-only file system")
}
/// Focus on the approval payload: the skill should prompt before execution and
/// only advertise the permissions declared in its metadata.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork skill prompt test")? else {
return Ok(());
};
let server = start_mock_server().await;
let tool_call_id = "zsh-fork-skill-call";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
|home| {
write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap();
write_skill_metadata(
home,
@@ -171,25 +286,11 @@ permissions:
"#,
)
.unwrap();
})
.with_config(move |config| {
config.features.enable(Feature::ShellTool);
config.features.enable(Feature::ShellZshFork);
config.zsh_path = Some(zsh_path.clone());
config.main_execve_wrapper_exe = Some(main_execve_wrapper_exe);
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.permissions.sandbox_policy =
Constrained::allow_any(SandboxPolicy::new_workspace_write_policy());
});
let test = builder.build(&server).await?;
},
)
.await?;
let script_path = fs::canonicalize(
test.codex_home_path()
.join("skills/mbolin-test-skill/scripts/hello-mbolin.sh"),
)?;
let script_path_str = script_path.to_string_lossy().into_owned();
let command = shlex::try_join([script_path_str.as_str()])?;
let (script_path_str, command) = skill_script_command(&test, "hello-mbolin.sh")?;
let arguments = shell_command_arguments(&command)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
@@ -203,12 +304,7 @@ permissions:
)
.await?;
let maybe_approval = wait_for_event_match(test.codex.as_ref(), |event| match event {
EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())),
EventMsg::TurnComplete(_) => Some(None),
_ => None,
})
.await;
let maybe_approval = wait_for_exec_approval_request(&test).await;
let approval = match maybe_approval {
Some(approval) => approval,
None => {
@@ -250,10 +346,7 @@ permissions:
})
.await?;
wait_for_event(test.codex.as_ref(), |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
wait_for_turn_complete(&test).await;
let call_output = mocks
.completion
@@ -268,58 +361,350 @@ permissions:
Ok(())
}
/// Look for `additional_permissions == None`, then verify that both the first
/// run and the cached session-approval rerun stay inside the turn sandbox.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork inherited skill sandbox test")? else {
return Ok(());
};
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let outside_path = outside_dir
.path()
.join("zsh-fork-skill-inherited-sandbox.txt");
let outside_path_quoted = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
let script_contents = format!(
"#!/bin/sh\nprintf '%s' forbidden > {outside_path_quoted}\ncat {outside_path_quoted}\n"
);
let outside_path_for_hook = outside_path.clone();
let script_contents_for_hook = script_contents.clone();
let workspace_write_policy = restrictive_workspace_write_policy();
let server = start_mock_server().await;
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::OnRequest,
workspace_write_policy.clone(),
move |home| {
let _ = fs::remove_file(&outside_path_for_hook);
write_skill_with_shell_script_contents(
home,
"mbolin-test-skill",
"sandboxed.sh",
&script_contents_for_hook,
)
.unwrap();
},
)
.await?;
let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?;
let first_call_id = "zsh-fork-skill-permissions-1";
let first_arguments = shell_command_arguments(&command)?;
let first_mocks = mount_function_call_agent_response(
&server,
first_call_id,
&first_arguments,
"shell_command",
)
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
workspace_write_policy.clone(),
)
.await?;
let maybe_approval = wait_for_exec_approval_request(&test).await;
let approval = match maybe_approval {
Some(approval) => approval,
None => panic!("expected exec approval request before completion"),
};
assert_eq!(approval.call_id, first_call_id);
assert_eq!(approval.command, vec![script_path_str.clone()]);
assert_eq!(approval.additional_permissions, None);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForSession,
})
.await?;
wait_for_turn_complete(&test).await;
let first_output = first_mocks
.completion
.single_request()
.function_call_output(first_call_id)["output"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
output_shows_sandbox_denial(&first_output) || !first_output.contains("forbidden"),
"expected inherited turn sandbox denial on first run, got output: {first_output:?}"
);
assert!(
!outside_path.exists(),
"first run should not write outside the turn sandbox"
);
let second_call_id = "zsh-fork-skill-permissions-2";
let second_arguments = shell_command_arguments(&command)?;
let second_mocks = mount_function_call_agent_response(
&server,
second_call_id,
&second_arguments,
"shell_command",
)
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
workspace_write_policy,
)
.await?;
let cached_approval = wait_for_exec_approval_request(&test).await;
assert!(
cached_approval.is_none(),
"expected second run to reuse the cached session approval"
);
let second_output = second_mocks
.completion
.single_request()
.function_call_output(second_call_id)["output"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
output_shows_sandbox_denial(&second_output) || !second_output.contains("forbidden"),
"expected cached skill approval to retain inherited turn sandboxing, got output: {second_output:?}"
);
assert!(
!outside_path.exists(),
"cached session approval should not widen a permissionless skill to full access"
);
Ok(())
}
/// The validation to focus on is: writes to the skill-approved folder succeed,
/// and writes to an unrelated folder fail, both before and after cached approval.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_session_approval_enforces_skill_permissions() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork explicit skill sandbox test")? else {
return Ok(());
};
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let allowed_dir = outside_dir.path().join("allowed-output");
let blocked_dir = outside_dir.path().join("blocked-output");
fs::create_dir_all(&allowed_dir)?;
fs::create_dir_all(&blocked_dir)?;
let allowed_path = allowed_dir.join("allowed.txt");
let blocked_path = blocked_dir.join("blocked.txt");
let allowed_path_quoted = shlex::try_join([allowed_path.to_string_lossy().as_ref()])?;
let blocked_path_quoted = shlex::try_join([blocked_path.to_string_lossy().as_ref()])?;
let script_contents = format!(
"#!/bin/sh\nprintf '%s' allowed > {allowed_path_quoted}\ncat {allowed_path_quoted}\nprintf '%s' forbidden > {blocked_path_quoted}\nif [ -f {blocked_path_quoted} ]; then echo blocked-created; fi\n"
);
let allowed_dir_for_hook = allowed_dir.clone();
let allowed_path_for_hook = allowed_path.clone();
let blocked_path_for_hook = blocked_path.clone();
let script_contents_for_hook = script_contents.clone();
let permissions_yaml = format!(
"permissions:\n file_system:\n write:\n - \"{}\"\n",
allowed_dir.display()
);
let workspace_write_policy = restrictive_workspace_write_policy();
let server = start_mock_server().await;
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::OnRequest,
workspace_write_policy.clone(),
move |home| {
let _ = fs::remove_file(&allowed_path_for_hook);
let _ = fs::remove_file(&blocked_path_for_hook);
fs::create_dir_all(&allowed_dir_for_hook).unwrap();
fs::create_dir_all(blocked_path_for_hook.parent().unwrap()).unwrap();
write_skill_with_shell_script_contents(
home,
"mbolin-test-skill",
"sandboxed.sh",
&script_contents_for_hook,
)
.unwrap();
write_skill_metadata(home, "mbolin-test-skill", &permissions_yaml).unwrap();
},
)
.await?;
let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?;
let first_call_id = "zsh-fork-skill-permissions-1";
let first_arguments = shell_command_arguments(&command)?;
let first_mocks = mount_function_call_agent_response(
&server,
first_call_id,
&first_arguments,
"shell_command",
)
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
workspace_write_policy.clone(),
)
.await?;
let maybe_approval = wait_for_exec_approval_request(&test).await;
let approval = match maybe_approval {
Some(approval) => approval,
None => panic!("expected exec approval request before completion"),
};
assert_eq!(approval.call_id, first_call_id);
assert_eq!(approval.command, vec![script_path_str.clone()]);
assert_eq!(
approval.additional_permissions,
Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![allowed_dir.clone()]),
}),
..Default::default()
})
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForSession,
})
.await?;
wait_for_turn_complete(&test).await;
let first_output = first_mocks
.completion
.single_request()
.function_call_output(first_call_id)["output"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
first_output.contains("allowed"),
"expected skill sandbox to permit writes to the approved folder, got output: {first_output:?}"
);
assert_eq!(fs::read_to_string(&allowed_path)?, "allowed");
assert!(
!blocked_path.exists(),
"first run should not write outside the explicit skill sandbox"
);
assert!(
!first_output.contains("blocked-created"),
"blocked path should not have been created: {first_output:?}"
);
let second_call_id = "zsh-fork-skill-permissions-2";
let second_arguments = shell_command_arguments(&command)?;
let second_mocks = mount_function_call_agent_response(
&server,
second_call_id,
&second_arguments,
"shell_command",
)
.await;
let _ = fs::remove_file(&allowed_path);
let _ = fs::remove_file(&blocked_path);
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
workspace_write_policy,
)
.await?;
let cached_approval = wait_for_exec_approval_request(&test).await;
assert!(
cached_approval.is_none(),
"expected second run to reuse the cached session approval"
);
let second_output = second_mocks
.completion
.single_request()
.function_call_output(second_call_id)["output"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
second_output.contains("allowed"),
"expected cached skill approval to retain the explicit skill sandbox, got output: {second_output:?}"
);
assert_eq!(fs::read_to_string(&allowed_path)?, "allowed");
assert!(
!blocked_path.exists(),
"cached session approval should not widen skill execution beyond the explicit skill sandbox"
);
assert!(
!second_output.contains("blocked-created"),
"blocked path should not have been created after cached approval: {second_output:?}"
);
Ok(())
}
/// This stays narrow on purpose: the important check is that `WorkspaceWrite`
/// continues to deny writes outside the workspace even under `zsh-fork`.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
use codex_config::Constrained;
use codex_protocol::protocol::AskForApproval;
skip_if_no_network!(Ok(()));
let Some(zsh_path) = find_test_zsh_path()? else {
return Ok(());
};
if !supports_exec_wrapper_intercept(&zsh_path) {
eprintln!(
"skipping zsh-fork sandbox test: zsh does not support EXEC_WRAPPER intercepts ({})",
zsh_path.display()
);
return Ok(());
}
let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")
else {
eprintln!(
"skipping zsh-fork sandbox test: unable to resolve `codex-execve-wrapper` binary"
);
let Some(runtime) = zsh_fork_runtime("zsh-fork workspace sandbox test")? else {
return Ok(());
};
let server = start_mock_server().await;
let tool_call_id = "zsh-fork-workspace-write-deny";
let outside_path = "/tmp/codex-zsh-fork-workspace-write-deny.txt";
let workspace_write_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let policy_for_config = workspace_write_policy.clone();
let workspace_write_policy = restrictive_workspace_write_policy();
let _ = fs::remove_file(outside_path);
let mut builder = test_codex()
.with_pre_build_hook(move |_| {
let test = build_zsh_fork_test(
&server,
runtime,
AskForApproval::Never,
workspace_write_policy.clone(),
move |_| {
let _ = fs::remove_file(outside_path);
})
.with_config(move |config| {
config.features.enable(Feature::ShellTool);
config.features.enable(Feature::ShellZshFork);
config.zsh_path = Some(zsh_path.clone());
config.main_execve_wrapper_exe = Some(main_execve_wrapper_exe);
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config.permissions.sandbox_policy = Constrained::allow_any(policy_for_config);
});
let test = builder.build(&server).await?;
},
)
.await?;
let command = format!("touch {outside_path}");
let arguments = shell_command_arguments(&command)?;
@@ -335,7 +720,7 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
)
.await?;
wait_for_turn_complete_without_skill_approval(&test).await;
wait_for_turn_complete(&test).await;
let call_output = mocks
.completion
@@ -343,9 +728,7 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
.function_call_output(tool_call_id);
let output = call_output["output"].as_str().unwrap_or_default();
assert!(
output.contains("Permission denied")
|| output.contains("Operation not permitted")
|| output.contains("Read-only file system"),
output_shows_sandbox_denial(output),
"expected sandbox denial, got output: {output:?}"
);
assert!(