feat: include sandbox config with escalation request

This commit is contained in:
Michael Bolin
2026-02-25 19:44:40 -08:00
parent b65205fb3d
commit 98a7aa4a87
18 changed files with 849 additions and 114 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2299,6 +2299,7 @@ dependencies = [
"anyhow",
"async-trait",
"clap",
"codex-protocol",
"codex-utils-absolute-path",
"libc",
"pretty_assertions",

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;

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,

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());

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 {

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,

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

View File

@@ -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,40 @@ 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 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)
})
}
async fn prompt(
&self,
program: &AbsolutePathBuf,
@@ -265,22 +315,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 +337,7 @@ impl CoreShellActionProvider {
argv,
workdir,
&self.stopwatch,
additional_permissions,
prompt_permissions,
&decision_source,
)
.await?
@@ -296,9 +345,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 +372,9 @@ impl CoreShellActionProvider {
}
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
ReviewDecision::NetworkPolicyAmendment {
@@ -333,29 +382,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 +422,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 +443,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());
.and_then(Self::skill_escalation_permissions);
return Ok(EscalateAction::Escalate);
return Ok(EscalationDecision::escalate(permissions));
}
// In the usual case, the execve wrapper reports the command being
@@ -424,6 +470,7 @@ impl EscalationPolicy for CoreShellActionProvider {
argv,
workdir,
skill.permission_profile.clone(),
Self::skill_escalation_permissions(&skill),
decision_source,
)
.await;
@@ -470,7 +517,8 @@ impl EscalationPolicy for CoreShellActionProvider {
program,
argv,
workdir,
None,
self.prompt_permissions.clone(),
self.escalation_permissions.clone(),
decision_source,
)
.await
@@ -488,6 +536,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 +584,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 +764,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 +913,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
);
}
}

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,

View File

@@ -65,6 +65,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 +100,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)?;
@@ -268,6 +280,207 @@ permissions:
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_session_approval_enforces_skill_permissions() -> Result<()> {
use codex_config::Constrained;
use codex_protocol::protocol::ReviewDecision;
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 skill permissions 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 skill permissions test: unable to resolve `codex-execve-wrapper` binary"
);
return Ok(());
};
let outside_dir = tempfile::tempdir()?;
let outside_path = outside_dir.path().join("zsh-fork-skill-permissions.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 server = start_mock_server().await;
let mut builder = test_codex()
.with_pre_build_hook(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();
write_skill_metadata(
home,
"mbolin-test-skill",
r#"
permissions:
file_system:
write:
- "./output"
"#,
)
.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::DangerFullAccess);
});
let test = builder.build(&server).await?;
let script_path = fs::canonicalize(
test.codex_home_path()
.join("skills/mbolin-test-skill/scripts/sandboxed.sh"),
)?;
let script_path_str = script_path.to_string_lossy().into_owned();
let command = shlex::try_join([script_path_str.as_str()])?;
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,
SandboxPolicy::DangerFullAccess,
)
.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 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![PathBuf::from("./output")]),
}),
..Default::default()
})
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForSession,
})
.await?;
wait_for_event(test.codex.as_ref(), |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.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("Permission denied")
|| first_output.contains("Operation not permitted")
|| first_output.contains("Read-only file system")
|| !first_output.contains("forbidden"),
"expected skill sandbox denial on first run, got output: {first_output:?}"
);
assert!(
!outside_path.exists(),
"first run should not write outside the approved skill 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,
SandboxPolicy::DangerFullAccess,
)
.await?;
let cached_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;
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("Permission denied")
|| second_output.contains("Operation not permitted")
|| second_output.contains("Read-only file system")
|| !second_output.contains("forbidden"),
"expected cached skill approval to retain sandboxing, got output: {second_output:?}"
);
assert!(
!outside_path.exists(),
"cached session approval should not widen skill execution to full access"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {

View File

@@ -2,15 +2,30 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::mcp::RequestId;
use crate::models::MacOsSeatbeltProfileExtensions;
use crate::models::PermissionProfile;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Permissions {
pub sandbox_policy: SandboxPolicy,
pub macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EscalationPermissions {
PermissionProfile(PermissionProfile),
Permissions(Permissions),
}
/// Proposed execpolicy change to allow commands starting with this prefix.
///
/// The `command` tokens form the prefix that would be added as an execpolicy

View File

@@ -95,6 +95,32 @@ pub enum MacOsAutomationValue {
BundleIds(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MacOsPreferencesPermission {
// IMPORTANT: ReadOnly needs to be the default because it's the
// security-sensitive default and keeps cf prefs working.
#[default]
ReadOnly,
ReadWrite,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MacOsAutomationPermission {
#[default]
None,
All,
BundleIds(Vec<String>),
}
#[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,
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct PermissionProfile {
pub network: Option<bool>,

View File

@@ -12,6 +12,7 @@ path = "src/bin/main_execve_wrapper.rs"
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
libc = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@@ -6,12 +6,20 @@ pub use unix::EscalateAction;
#[cfg(unix)]
pub use unix::EscalateServer;
#[cfg(unix)]
pub use unix::EscalationDecision;
#[cfg(unix)]
pub use unix::EscalationPermissions;
#[cfg(unix)]
pub use unix::EscalationPolicy;
#[cfg(unix)]
pub use unix::ExecParams;
#[cfg(unix)]
pub use unix::ExecResult;
#[cfg(unix)]
pub use unix::Permissions;
#[cfg(unix)]
pub use unix::PreparedExec;
#[cfg(unix)]
pub use unix::ShellCommandExecutor;
#[cfg(unix)]
pub use unix::Stopwatch;

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::os::fd::RawFd;
use std::path::PathBuf;
use codex_protocol::approvals::EscalationPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
@@ -35,6 +36,35 @@ pub struct EscalateResponse {
pub action: EscalateAction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EscalationDecision {
pub action: EscalateAction,
pub permissions: Option<EscalationPermissions>,
}
impl EscalationDecision {
pub fn run() -> Self {
Self {
action: EscalateAction::Run,
permissions: None,
}
}
pub fn escalate(permissions: Option<EscalationPermissions>) -> Self {
Self {
action: EscalateAction::Escalate,
permissions,
}
}
pub fn deny(reason: Option<String>) -> Self {
Self {
action: EscalateAction::Deny { reason },
permissions: None,
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub enum EscalateAction {
/// The command should be run directly by the client.

View File

@@ -6,6 +6,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use codex_protocol::approvals::EscalationPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
@@ -15,6 +16,7 @@ use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
use crate::unix::escalate_protocol::EscalateAction;
use crate::unix::escalate_protocol::EscalateRequest;
use crate::unix::escalate_protocol::EscalateResponse;
use crate::unix::escalate_protocol::EscalationDecision;
use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
use crate::unix::escalate_protocol::SuperExecMessage;
use crate::unix::escalate_protocol::SuperExecResult;
@@ -37,6 +39,16 @@ pub trait ShellCommandExecutor: Send + Sync {
env: HashMap<String, String>,
cancel_rx: CancellationToken,
) -> anyhow::Result<ExecResult>;
/// Prepares an escalated subcommand for execution on the server side.
async fn prepare_escalated_exec(
&self,
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
permissions: Option<EscalationPermissions>,
) -> anyhow::Result<PreparedExec>;
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -62,6 +74,14 @@ pub struct ExecResult {
pub timed_out: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedExec {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub arg0: Option<String>,
}
pub struct EscalateServer {
bash_path: PathBuf,
execve_wrapper: PathBuf,
@@ -69,9 +89,9 @@ pub struct EscalateServer {
}
impl EscalateServer {
pub fn new<P>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: P) -> Self
pub fn new<Policy>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: Policy) -> Self
where
P: EscalationPolicy + Send + Sync + 'static,
Policy: EscalationPolicy + Send + Sync + 'static,
{
Self {
bash_path,
@@ -84,13 +104,17 @@ impl EscalateServer {
&self,
params: ExecParams,
cancel_rx: CancellationToken,
command_executor: &dyn ShellCommandExecutor,
command_executor: Arc<dyn ShellCommandExecutor>,
) -> anyhow::Result<ExecResult> {
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
let client_socket = escalate_client.into_inner();
// Only the client endpoint should cross exec into the wrapper process.
client_socket.set_cloexec(false)?;
let escalate_task = tokio::spawn(escalate_task(escalate_server, self.policy.clone()));
let escalate_task = tokio::spawn(escalate_task(
escalate_server,
Arc::clone(&self.policy),
Arc::clone(&command_executor),
));
let mut env = std::env::vars().collect::<HashMap<String, String>>();
env.insert(
ESCALATE_SOCKET_ENV_VAR.to_string(),
@@ -126,6 +150,7 @@ impl EscalateServer {
async fn escalate_task(
socket: AsyncDatagramSocket,
policy: Arc<dyn EscalationPolicy>,
command_executor: Arc<dyn ShellCommandExecutor>,
) -> anyhow::Result<()> {
loop {
let (_, mut fds) = socket.receive_with_fds().await?;
@@ -134,9 +159,12 @@ async fn escalate_task(
continue;
}
let stream_socket = AsyncSocket::from_fd(fds.remove(0))?;
let policy = policy.clone();
let policy = Arc::clone(&policy);
let command_executor = Arc::clone(&command_executor);
tokio::spawn(async move {
if let Err(err) = handle_escalate_session_with_policy(stream_socket, policy).await {
if let Err(err) =
handle_escalate_session_with_policy(stream_socket, policy, command_executor).await
{
tracing::error!("escalate session failed: {err:?}");
}
});
@@ -146,6 +174,7 @@ async fn escalate_task(
async fn handle_escalate_session_with_policy(
socket: AsyncSocket,
policy: Arc<dyn EscalationPolicy>,
command_executor: Arc<dyn ShellCommandExecutor>,
) -> anyhow::Result<()> {
let EscalateRequest {
file,
@@ -154,7 +183,10 @@ async fn handle_escalate_session_with_policy(
env,
} = socket.receive::<EscalateRequest>().await?;
let program = AbsolutePathBuf::resolve_path_against_base(file, workdir.as_path())?;
let action = policy
let EscalationDecision {
action,
permissions,
} = policy
.determine_action(&program, &argv, &workdir)
.await
.context("failed to determine escalation action")?;
@@ -197,12 +229,23 @@ async fn handle_escalate_session_with_policy(
));
}
let mut command = Command::new(program.as_path());
let PreparedExec {
command,
cwd,
env,
arg0,
} = command_executor
.prepare_escalated_exec(&program, &argv, &workdir, env, permissions)
.await?;
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow::anyhow!("prepared escalated command must not be empty"))?;
let mut command = Command::new(program);
command
.args(&argv[1..])
.arg0(argv[0].clone())
.args(args)
.arg0(arg0.unwrap_or_else(|| program.clone()))
.envs(&env)
.current_dir(&workdir)
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
@@ -236,13 +279,14 @@ async fn handle_escalate_session_with_policy(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
struct DeterministicEscalationPolicy {
action: EscalateAction,
decision: EscalationDecision,
}
#[async_trait::async_trait]
@@ -252,8 +296,8 @@ mod tests {
_file: &AbsolutePathBuf,
_argv: &[String],
_workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction> {
Ok(self.action.clone())
) -> anyhow::Result<EscalationDecision> {
Ok(self.decision.clone())
}
}
@@ -269,10 +313,79 @@ mod tests {
file: &AbsolutePathBuf,
_argv: &[String],
workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction> {
) -> anyhow::Result<EscalationDecision> {
assert_eq!(file, &self.expected_file);
assert_eq!(workdir, &self.expected_workdir);
Ok(EscalateAction::Run)
Ok(EscalationDecision::run())
}
}
struct ForwardingShellCommandExecutor;
#[async_trait::async_trait]
impl ShellCommandExecutor for ForwardingShellCommandExecutor {
async fn run(
&self,
_command: Vec<String>,
_cwd: PathBuf,
_env: HashMap<String, String>,
_cancel_rx: CancellationToken,
) -> anyhow::Result<ExecResult> {
unreachable!("run() is not used by handle_escalate_session_with_policy() tests")
}
async fn prepare_escalated_exec(
&self,
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
_permissions: Option<EscalationPermissions>,
) -> anyhow::Result<PreparedExec> {
Ok(PreparedExec {
command: std::iter::once(program.to_string_lossy().to_string())
.chain(argv.iter().skip(1).cloned())
.collect(),
cwd: workdir.to_path_buf(),
env,
arg0: argv.first().cloned(),
})
}
}
struct PermissionAssertingShellCommandExecutor {
expected_permissions: EscalationPermissions,
}
#[async_trait::async_trait]
impl ShellCommandExecutor for PermissionAssertingShellCommandExecutor {
async fn run(
&self,
_command: Vec<String>,
_cwd: PathBuf,
_env: HashMap<String, String>,
_cancel_rx: CancellationToken,
) -> anyhow::Result<ExecResult> {
unreachable!("run() is not used by handle_escalate_session_with_policy() tests")
}
async fn prepare_escalated_exec(
&self,
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
permissions: Option<EscalationPermissions>,
) -> anyhow::Result<PreparedExec> {
assert_eq!(permissions, Some(self.expected_permissions.clone()));
Ok(PreparedExec {
command: std::iter::once(program.to_string_lossy().to_string())
.chain(argv.iter().skip(1).cloned())
.collect(),
cwd: workdir.to_path_buf(),
env,
arg0: argv.first().cloned(),
})
}
}
@@ -282,8 +395,9 @@ mod tests {
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
Arc::new(DeterministicEscalationPolicy {
action: EscalateAction::Run,
decision: EscalationDecision::run(),
}),
Arc::new(ForwardingShellCommandExecutor),
));
let mut env = HashMap::new();
@@ -326,6 +440,7 @@ mod tests {
expected_file,
expected_workdir: workdir.clone(),
}),
Arc::new(ForwardingShellCommandExecutor),
));
client
@@ -353,8 +468,9 @@ mod tests {
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
Arc::new(DeterministicEscalationPolicy {
action: EscalateAction::Escalate,
decision: EscalationDecision::escalate(None),
}),
Arc::new(ForwardingShellCommandExecutor),
));
client
@@ -387,4 +503,52 @@ mod tests {
server_task.await?
}
#[tokio::test]
async fn handle_escalate_session_passes_permissions_to_executor() -> anyhow::Result<()> {
let (server, client) = AsyncSocket::pair()?;
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
Arc::new(DeterministicEscalationPolicy {
decision: EscalationDecision::escalate(Some(
EscalationPermissions::PermissionProfile(PermissionProfile {
network: Some(true),
..Default::default()
}),
)),
}),
Arc::new(PermissionAssertingShellCommandExecutor {
expected_permissions: EscalationPermissions::PermissionProfile(PermissionProfile {
network: Some(true),
..Default::default()
}),
}),
));
client
.send(EscalateRequest {
file: PathBuf::from("/bin/sh"),
argv: vec!["sh".to_string(), "-c".to_string(), "exit 0".to_string()],
workdir: AbsolutePathBuf::current_dir()?,
env: HashMap::new(),
})
.await?;
let response = client.receive::<EscalateResponse>().await?;
assert_eq!(
EscalateResponse {
action: EscalateAction::Escalate,
},
response
);
client
.send_with_fds(SuperExecMessage { fds: Vec::new() }, &[])
.await?;
let result = client.receive::<SuperExecResult>().await?;
assert_eq!(0, result.exit_code);
server_task.await?
}
}

View File

@@ -1,6 +1,6 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::unix::escalate_protocol::EscalateAction;
use crate::unix::escalate_protocol::EscalationDecision;
/// Decides what action to take in response to an execve request from a client.
#[async_trait::async_trait]
@@ -10,5 +10,5 @@ pub trait EscalationPolicy: Send + Sync {
file: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction>;
) -> anyhow::Result<EscalationDecision>;
}

View File

@@ -63,10 +63,14 @@ pub mod stopwatch;
pub use self::escalate_client::run_shell_escalation_execve_wrapper;
pub use self::escalate_protocol::EscalateAction;
pub use self::escalate_protocol::EscalationDecision;
pub use self::escalate_server::EscalateServer;
pub use self::escalate_server::ExecParams;
pub use self::escalate_server::ExecResult;
pub use self::escalate_server::PreparedExec;
pub use self::escalate_server::ShellCommandExecutor;
pub use self::escalation_policy::EscalationPolicy;
pub use self::execve_wrapper::main_execve_wrapper;
pub use self::stopwatch::Stopwatch;
pub use codex_protocol::approvals::EscalationPermissions;
pub use codex_protocol::approvals::Permissions;