feat: include sandbox config with escalation request

This commit is contained in:
Michael Bolin
2026-02-25 16:11:46 -08:00
parent e76b1a2853
commit 43b6e27c30
12 changed files with 752 additions and 89 deletions

View File

@@ -219,6 +219,7 @@ pub async fn process_exec_tool_call(
enforce_managed_network,
network: network.as_ref(),
sandbox_policy_cwd: sandbox_cwd,
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,11 @@ 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::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -73,6 +77,7 @@ 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,
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 +347,7 @@ impl SandboxManager {
enforce_managed_network,
network,
sandbox_policy_cwd,
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

@@ -616,6 +616,7 @@ impl JsReplManager {
enforce_managed_network: has_managed_network_requirements,
network: None,
sandbox_policy_cwd: &turn.cwd,
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap: turn
.features

View File

@@ -4,6 +4,7 @@ Module: runtimes
Concrete ToolRuntime implementations for specific tools. Each runtime stays
small and focused and reuses the orchestrator for approvals + sandbox + retry.
*/
use crate::config::Permissions;
use crate::exec::ExecExpiration;
use crate::path_utils;
use crate::sandboxing::CommandSpec;
@@ -19,6 +20,13 @@ pub mod apply_patch;
pub mod shell;
pub mod unified_exec;
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum EscalationPermissions {
PermissionProfile(PermissionProfile),
Permissions(Permissions),
}
#[derive(Debug, Clone)]
pub(crate) struct ExecveSessionApproval {
/// If this execve session approval is associated with a skill script, this

View File

@@ -7,11 +7,15 @@ use crate::exec::SandboxType;
use crate::exec::is_likely_sandbox_denied;
use crate::features::Feature;
use crate::sandboxing::SandboxPermissions;
#[cfg(target_os = "macos")]
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use crate::shell::ShellType;
use crate::skills::SkillMetadata;
use crate::tools::runtimes::EscalationPermissions;
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;
@@ -26,11 +30,12 @@ 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::EscalationPolicy;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::PreparedExec;
use codex_shell_escalation::ShellCommandExecutor;
use codex_shell_escalation::Stopwatch;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -42,6 +47,9 @@ use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
pub(super) async fn try_run_zsh_fork(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
@@ -105,6 +113,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 +147,10 @@ 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,
escalation_permissions: req
.additional_permissions
.clone()
.map(EscalationPermissions::PermissionProfile),
stopwatch: stopwatch.clone(),
};
@@ -146,7 +161,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 +176,7 @@ struct CoreShellActionProvider {
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
sandbox_permissions: SandboxPermissions,
escalation_permissions: Option<EscalationPermissions>,
stopwatch: Stopwatch,
}
@@ -182,6 +198,30 @@ impl CoreShellActionProvider {
})
}
fn prompt_permissions(
escalation_permissions: Option<&EscalationPermissions>,
) -> Option<PermissionProfile> {
match escalation_permissions {
Some(EscalationPermissions::PermissionProfile(permission_profile)) => {
Some(permission_profile.clone())
}
Some(EscalationPermissions::Permissions(_)) | None => None,
}
}
fn skill_escalation_permissions(skill: &SkillMetadata) -> Option<EscalationPermissions> {
skill
.permissions
.clone()
.map(EscalationPermissions::Permissions)
.or_else(|| {
skill
.permission_profile
.clone()
.map(EscalationPermissions::PermissionProfile)
})
}
async fn prompt(
&self,
program: &AbsolutePathBuf,
@@ -249,39 +289,32 @@ 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<EscalationPermissions>> {
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(
program,
argv,
workdir,
&self.stopwatch,
additional_permissions,
)
.prompt(program, argv, workdir, &self.stopwatch, prompt_permissions)
.await?
{
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
ReviewDecision::ApprovedForSession => {
@@ -306,9 +339,9 @@ impl CoreShellActionProvider {
}
if needs_escalation {
EscalateAction::Escalate
EscalationDecision::escalate(escalation_permissions)
} else {
EscalateAction::Run
EscalationDecision::run()
}
}
ReviewDecision::NetworkPolicyAmendment {
@@ -316,29 +349,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()
}
}
};
@@ -350,13 +383,13 @@ impl CoreShellActionProvider {
}
#[async_trait::async_trait]
impl EscalationPolicy for CoreShellActionProvider {
impl EscalationPolicy<EscalationPermissions> for CoreShellActionProvider {
async fn determine_action(
&self,
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction> {
) -> anyhow::Result<EscalationDecision<EscalationPermissions>> {
tracing::debug!(
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
);
@@ -377,15 +410,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
@@ -407,6 +437,7 @@ impl EscalationPolicy for CoreShellActionProvider {
argv,
workdir,
skill.permission_profile.clone(),
Self::skill_escalation_permissions(&skill),
decision_source,
)
.await;
@@ -453,7 +484,8 @@ impl EscalationPolicy for CoreShellActionProvider {
program,
argv,
workdir,
None,
Self::prompt_permissions(self.escalation_permissions.as_ref()),
self.escalation_permissions.clone(),
decision_source,
)
.await
@@ -471,10 +503,13 @@ 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]
impl ShellCommandExecutor for CoreShellCommandExecutor {
impl ShellCommandExecutor<EscalationPermissions> for CoreShellCommandExecutor {
async fn run(
&self,
_command: Vec<String>,
@@ -516,6 +551,112 @@ 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.get(),
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>,
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,
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)]
@@ -584,14 +725,42 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<Stri
#[cfg(test)]
mod tests {
#[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;
#[cfg(target_os = "macos")]
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 crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(target_os = "macos")]
use crate::tools::runtimes::EscalationPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_shell_escalation::ExecResult;
#[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::time::Duration;
#[test]
@@ -701,4 +870,63 @@ mod tests {
assert_eq!(out.stderr.text, "err");
assert_eq!(out.aggregated_output.text, "outerr");
}
#[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(permissions)),
)
.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,7 @@ impl<'a> SandboxAttempt<'a> {
enforce_managed_network: self.enforce_managed_network,
network,
sandbox_policy_cwd: self.sandbox_cwd,
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

@@ -76,6 +76,24 @@ async fn wait_for_turn_complete_without_skill_approval(test: &TestCodex) {
}
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);
@@ -93,13 +111,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)?;
@@ -275,6 +287,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

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

View File

@@ -35,6 +35,35 @@ pub struct EscalateResponse {
pub action: EscalateAction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EscalationDecision<P> {
pub action: EscalateAction,
pub permissions: Option<P>,
}
impl<P> EscalationDecision<P> {
pub fn run() -> Self {
Self {
action: EscalateAction::Run,
permissions: None,
}
}
pub fn escalate(permissions: Option<P>) -> 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

@@ -15,6 +15,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;
@@ -28,7 +29,7 @@ use crate::unix::socket::AsyncSocket;
/// keeps control over process spawning, output capture, and sandbox integration.
/// Implementations can capture any sandbox state they need.
#[async_trait::async_trait]
pub trait ShellCommandExecutor: Send + Sync {
pub trait ShellCommandExecutor<P>: Send + Sync {
/// Runs the requested shell command and returns the captured result.
async fn run(
&self,
@@ -37,6 +38,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<P>,
) -> anyhow::Result<PreparedExec>;
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -62,16 +73,27 @@ pub struct ExecResult {
pub timed_out: bool,
}
pub struct EscalateServer {
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: Arc<dyn EscalationPolicy>,
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedExec {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub arg0: Option<String>,
}
impl EscalateServer {
pub fn new<P>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: P) -> Self
pub struct EscalateServer<P> {
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: Arc<dyn EscalationPolicy<P>>,
}
impl<P> EscalateServer<P>
where
P: Send + Sync + 'static,
{
pub fn new<Policy>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: Policy) -> Self
where
P: EscalationPolicy + Send + Sync + 'static,
Policy: EscalationPolicy<P> + Send + Sync + 'static,
{
Self {
bash_path,
@@ -84,13 +106,17 @@ impl EscalateServer {
&self,
params: ExecParams,
cancel_rx: CancellationToken,
command_executor: &dyn ShellCommandExecutor,
command_executor: Arc<dyn ShellCommandExecutor<P>>,
) -> 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(),
@@ -123,10 +149,14 @@ impl EscalateServer {
}
}
async fn escalate_task(
async fn escalate_task<P>(
socket: AsyncDatagramSocket,
policy: Arc<dyn EscalationPolicy>,
) -> anyhow::Result<()> {
policy: Arc<dyn EscalationPolicy<P>>,
command_executor: Arc<dyn ShellCommandExecutor<P>>,
) -> anyhow::Result<()>
where
P: Send + Sync + 'static,
{
loop {
let (_, mut fds) = socket.receive_with_fds().await?;
if fds.len() != 1 {
@@ -134,19 +164,26 @@ 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:?}");
}
});
}
}
async fn handle_escalate_session_with_policy(
async fn handle_escalate_session_with_policy<P>(
socket: AsyncSocket,
policy: Arc<dyn EscalationPolicy>,
) -> anyhow::Result<()> {
policy: Arc<dyn EscalationPolicy<P>>,
command_executor: Arc<dyn ShellCommandExecutor<P>>,
) -> anyhow::Result<()>
where
P: Send + Sync + 'static,
{
let EscalateRequest {
file,
argv,
@@ -154,7 +191,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 +237,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());
@@ -241,19 +292,22 @@ mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
struct DeterministicEscalationPolicy {
action: EscalateAction,
struct DeterministicEscalationPolicy<P> {
decision: EscalationDecision<P>,
}
#[async_trait::async_trait]
impl EscalationPolicy for DeterministicEscalationPolicy {
impl<P> EscalationPolicy<P> for DeterministicEscalationPolicy<P>
where
P: Clone + Send + Sync + 'static,
{
async fn determine_action(
&self,
_file: &AbsolutePathBuf,
_argv: &[String],
_workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction> {
Ok(self.action.clone())
) -> anyhow::Result<EscalationDecision<P>> {
Ok(self.decision.clone())
}
}
@@ -263,16 +317,88 @@ mod tests {
}
#[async_trait::async_trait]
impl EscalationPolicy for AssertingEscalationPolicy {
impl EscalationPolicy<()> for AssertingEscalationPolicy {
async fn determine_action(
&self,
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<P> ShellCommandExecutor<P> for ForwardingShellCommandExecutor
where
P: Send + Sync + 'static,
{
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<P>,
) -> 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: String,
}
#[async_trait::async_trait]
impl ShellCommandExecutor<String> 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<String>,
) -> 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 +408,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 +453,7 @@ mod tests {
expected_file,
expected_workdir: workdir.clone(),
}),
Arc::new(ForwardingShellCommandExecutor),
));
client
@@ -353,8 +481,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 +516,44 @@ 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("sandbox".to_string())),
}),
Arc::new(PermissionAssertingShellCommandExecutor {
expected_permissions: "sandbox".to_string(),
}),
));
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,14 +1,14 @@
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]
pub trait EscalationPolicy: Send + Sync {
pub trait EscalationPolicy<P>: Send + Sync {
async fn determine_action(
&self,
file: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
) -> anyhow::Result<EscalateAction>;
) -> anyhow::Result<EscalationDecision<P>>;
}

View File

@@ -63,9 +63,11 @@ 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;