mirror of
https://github.com/openai/codex.git
synced 2026-04-22 05:34:49 +00:00
Compare commits
9 Commits
codex-debu
...
dev/cc/ski
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be9296b05 | ||
|
|
79aee8f0da | ||
|
|
d0da0040d0 | ||
|
|
4112ef0490 | ||
|
|
514566f4c3 | ||
|
|
5cdf425515 | ||
|
|
c33c2a650f | ||
|
|
13ec48ccb7 | ||
|
|
c622cbef35 |
@@ -2,6 +2,7 @@ use std::collections::VecDeque;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Write;
|
||||
@@ -32,6 +33,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::DynamicToolSpec;
|
||||
use codex_app_server_protocol::ExecPolicyAmendment;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
@@ -143,10 +145,28 @@ struct Cli {
|
||||
#[arg(long, value_name = "json-or-@file", global = true)]
|
||||
dynamic_tools: Option<String>,
|
||||
|
||||
/// Attach a skill input item to V2 turn/start requests.
|
||||
///
|
||||
/// Must be paired with --skill-path.
|
||||
#[arg(long, value_name = "skill-name", global = true)]
|
||||
skill_name: Option<String>,
|
||||
|
||||
/// Path to the SKILL.md file for --skill-name.
|
||||
///
|
||||
/// Must be paired with --skill-name.
|
||||
#[arg(long, value_name = "path-to-skill-md", global = true)]
|
||||
skill_path: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: CliCommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SkillSelection {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CliCommand {
|
||||
/// Start `codex app-server` on a websocket endpoint in the background.
|
||||
@@ -241,25 +261,36 @@ pub fn run() -> Result<()> {
|
||||
url,
|
||||
config_overrides,
|
||||
dynamic_tools,
|
||||
skill_name,
|
||||
skill_path,
|
||||
command,
|
||||
} = Cli::parse();
|
||||
|
||||
let dynamic_tools = parse_dynamic_tools_arg(&dynamic_tools)?;
|
||||
let skill_selection = parse_skill_selection(skill_name, skill_path)?;
|
||||
|
||||
match command {
|
||||
CliCommand::Serve { listen, kill } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "serve")?;
|
||||
ensure_skill_unused(&skill_selection, "serve")?;
|
||||
let codex_bin = codex_bin.unwrap_or_else(|| PathBuf::from("codex"));
|
||||
serve(&codex_bin, &config_overrides, &listen, kill)
|
||||
}
|
||||
CliCommand::SendMessage { user_message } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?;
|
||||
ensure_skill_unused(&skill_selection, "send-message")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
send_message(&endpoint, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::SendMessageV2 { user_message } => {
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
send_message_v2_endpoint(&endpoint, &config_overrides, user_message, &dynamic_tools)
|
||||
send_message_v2_endpoint(
|
||||
&endpoint,
|
||||
&config_overrides,
|
||||
user_message,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::ResumeMessageV2 {
|
||||
thread_id,
|
||||
@@ -272,24 +303,43 @@ pub fn run() -> Result<()> {
|
||||
thread_id,
|
||||
user_message,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::ThreadResume { thread_id } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "thread-resume")?;
|
||||
ensure_skill_unused(&skill_selection, "thread-resume")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
thread_resume_follow(&endpoint, &config_overrides, thread_id)
|
||||
}
|
||||
CliCommand::TriggerCmdApproval { user_message } => {
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
|
||||
trigger_cmd_approval(
|
||||
&endpoint,
|
||||
&config_overrides,
|
||||
user_message,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::TriggerPatchApproval { user_message } => {
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
|
||||
trigger_patch_approval(
|
||||
&endpoint,
|
||||
&config_overrides,
|
||||
user_message,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::NoTriggerCmdApproval => {
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools)
|
||||
no_trigger_cmd_approval(
|
||||
&endpoint,
|
||||
&config_overrides,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::SendFollowUpV2 {
|
||||
first_message,
|
||||
@@ -302,6 +352,7 @@ pub fn run() -> Result<()> {
|
||||
first_message,
|
||||
follow_up_message,
|
||||
&dynamic_tools,
|
||||
skill_selection.as_ref(),
|
||||
)
|
||||
}
|
||||
CliCommand::TriggerZshForkMultiCmdApproval {
|
||||
@@ -321,21 +372,25 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
CliCommand::TestLogin => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
|
||||
ensure_skill_unused(&skill_selection, "test-login")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
test_login(&endpoint, &config_overrides)
|
||||
}
|
||||
CliCommand::GetAccountRateLimits => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
|
||||
ensure_skill_unused(&skill_selection, "get-account-rate-limits")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
get_account_rate_limits(&endpoint, &config_overrides)
|
||||
}
|
||||
CliCommand::ModelList => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?;
|
||||
ensure_skill_unused(&skill_selection, "model-list")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
model_list(&endpoint, &config_overrides)
|
||||
}
|
||||
CliCommand::ThreadList { limit } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "thread-list")?;
|
||||
ensure_skill_unused(&skill_selection, "thread-list")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
thread_list(&endpoint, &config_overrides, limit)
|
||||
}
|
||||
@@ -505,7 +560,13 @@ pub fn send_message_v2(
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
) -> Result<()> {
|
||||
let endpoint = Endpoint::SpawnCodex(codex_bin.to_path_buf());
|
||||
send_message_v2_endpoint(&endpoint, config_overrides, user_message, dynamic_tools)
|
||||
send_message_v2_endpoint(
|
||||
&endpoint,
|
||||
config_overrides,
|
||||
user_message,
|
||||
dynamic_tools,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn send_message_v2_endpoint(
|
||||
@@ -513,6 +574,7 @@ fn send_message_v2_endpoint(
|
||||
config_overrides: &[String],
|
||||
user_message: String,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
send_message_v2_with_policies(
|
||||
endpoint,
|
||||
@@ -521,6 +583,7 @@ fn send_message_v2_endpoint(
|
||||
None,
|
||||
None,
|
||||
dynamic_tools,
|
||||
skill_selection,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -625,6 +688,7 @@ fn resume_message_v2(
|
||||
thread_id: String,
|
||||
user_message: String,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?;
|
||||
|
||||
@@ -641,10 +705,7 @@ fn resume_message_v2(
|
||||
|
||||
let turn_response = client.turn_start(TurnStartParams {
|
||||
thread_id: resume_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
input: build_v2_input(user_message, skill_selection),
|
||||
..Default::default()
|
||||
})?;
|
||||
println!("< turn/start response: {turn_response:?}");
|
||||
@@ -679,6 +740,7 @@ fn trigger_cmd_approval(
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
|
||||
@@ -692,6 +754,7 @@ fn trigger_cmd_approval(
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
}),
|
||||
dynamic_tools,
|
||||
skill_selection,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -700,6 +763,7 @@ fn trigger_patch_approval(
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
|
||||
@@ -713,6 +777,7 @@ fn trigger_patch_approval(
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
}),
|
||||
dynamic_tools,
|
||||
skill_selection,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -720,6 +785,7 @@ fn no_trigger_cmd_approval(
|
||||
endpoint: &Endpoint,
|
||||
config_overrides: &[String],
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
let prompt = "Run `touch should_not_trigger_approval.txt`";
|
||||
send_message_v2_with_policies(
|
||||
@@ -729,6 +795,7 @@ fn no_trigger_cmd_approval(
|
||||
None,
|
||||
None,
|
||||
dynamic_tools,
|
||||
skill_selection,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -739,6 +806,7 @@ fn send_message_v2_with_policies(
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::connect(endpoint, config_overrides)?;
|
||||
|
||||
@@ -752,11 +820,7 @@ fn send_message_v2_with_policies(
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
let mut turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
input: build_v2_input(user_message, skill_selection),
|
||||
..Default::default()
|
||||
};
|
||||
turn_params.approval_policy = approval_policy;
|
||||
@@ -776,6 +840,7 @@ fn send_follow_up_v2(
|
||||
first_message: String,
|
||||
follow_up_message: String,
|
||||
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::connect(endpoint, config_overrides)?;
|
||||
|
||||
@@ -790,11 +855,7 @@ fn send_follow_up_v2(
|
||||
|
||||
let first_turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: first_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
input: build_v2_input(first_message, skill_selection),
|
||||
..Default::default()
|
||||
};
|
||||
let first_turn_response = client.turn_start(first_turn_params)?;
|
||||
@@ -803,11 +864,7 @@ fn send_follow_up_v2(
|
||||
|
||||
let follow_up_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: follow_up_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
input: build_v2_input(follow_up_message, skill_selection),
|
||||
..Default::default()
|
||||
};
|
||||
let follow_up_response = client.turn_start(follow_up_params)?;
|
||||
@@ -903,6 +960,47 @@ fn ensure_dynamic_tools_unused(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_skill_unused(skill_selection: &Option<SkillSelection>, command: &str) -> Result<()> {
|
||||
if skill_selection.is_some() {
|
||||
bail!(
|
||||
"skill input is only supported for v2 turn/start commands; remove --skill-name/--skill-path for {command}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_skill_selection(
|
||||
skill_name: Option<String>,
|
||||
skill_path: Option<PathBuf>,
|
||||
) -> Result<Option<SkillSelection>> {
|
||||
match (skill_name, skill_path) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(name), Some(path)) => Ok(Some(SkillSelection { name, path })),
|
||||
(Some(_), None) => bail!("--skill-name requires --skill-path"),
|
||||
(None, Some(_)) => bail!("--skill-path requires --skill-name"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_v2_input(
|
||||
user_message: String,
|
||||
skill_selection: Option<&SkillSelection>,
|
||||
) -> Vec<V2UserInput> {
|
||||
let mut input = vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
|
||||
if let Some(skill_selection) = skill_selection {
|
||||
input.push(V2UserInput::Skill {
|
||||
name: skill_selection.name.clone(),
|
||||
path: skill_selection.path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
fn parse_dynamic_tools_arg(dynamic_tools: &Option<String>) -> Result<Option<Vec<DynamicToolSpec>>> {
|
||||
let Some(raw_arg) = dynamic_tools.as_deref() else {
|
||||
return Ok(None);
|
||||
@@ -949,6 +1047,7 @@ struct CodexClient {
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CommandApprovalBehavior {
|
||||
Prompt,
|
||||
AlwaysAccept,
|
||||
AbortOn(usize),
|
||||
}
|
||||
@@ -999,7 +1098,7 @@ impl CodexClient {
|
||||
stdout: BufReader::new(stdout),
|
||||
},
|
||||
pending_notifications: VecDeque::new(),
|
||||
command_approval_behavior: CommandApprovalBehavior::AlwaysAccept,
|
||||
command_approval_behavior: CommandApprovalBehavior::Prompt,
|
||||
command_approval_count: 0,
|
||||
command_approval_item_ids: Vec::new(),
|
||||
command_execution_statuses: Vec::new(),
|
||||
@@ -1020,7 +1119,7 @@ impl CodexClient {
|
||||
socket: Box::new(socket),
|
||||
},
|
||||
pending_notifications: VecDeque::new(),
|
||||
command_approval_behavior: CommandApprovalBehavior::AlwaysAccept,
|
||||
command_approval_behavior: CommandApprovalBehavior::Prompt,
|
||||
command_approval_count: 0,
|
||||
command_approval_item_ids: Vec::new(),
|
||||
command_execution_statuses: Vec::new(),
|
||||
@@ -1522,19 +1621,20 @@ impl CodexClient {
|
||||
}
|
||||
|
||||
let decision = match self.command_approval_behavior {
|
||||
CommandApprovalBehavior::Prompt => {
|
||||
self.command_approval_decision(proposed_execpolicy_amendment)?
|
||||
}
|
||||
CommandApprovalBehavior::AlwaysAccept => CommandExecutionApprovalDecision::Accept,
|
||||
CommandApprovalBehavior::AbortOn(index) if self.command_approval_count == index => {
|
||||
CommandExecutionApprovalDecision::Cancel
|
||||
}
|
||||
CommandApprovalBehavior::AbortOn(_) => CommandExecutionApprovalDecision::Accept,
|
||||
};
|
||||
let response = CommandExecutionRequestApprovalResponse {
|
||||
decision: decision.clone(),
|
||||
};
|
||||
let response = CommandExecutionRequestApprovalResponse { decision };
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!(
|
||||
"< commandExecution decision for approval #{} on item {item_id}: {:?}",
|
||||
self.command_approval_count, decision
|
||||
self.command_approval_count, response.decision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1562,14 +1662,39 @@ impl CodexClient {
|
||||
println!("< grant root: {}", grant_root.display());
|
||||
}
|
||||
|
||||
let response = FileChangeRequestApprovalResponse {
|
||||
decision: FileChangeApprovalDecision::Accept,
|
||||
};
|
||||
let decision = self.file_change_approval_decision()?;
|
||||
let response = FileChangeRequestApprovalResponse { decision };
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!("< approved fileChange request for item {item_id}");
|
||||
println!(
|
||||
"< responded to fileChange request for item {item_id}: {:?}",
|
||||
response.decision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_approval_decision(
|
||||
&self,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
) -> Result<CommandExecutionApprovalDecision> {
|
||||
if let Some(execpolicy_amendment) = proposed_execpolicy_amendment {
|
||||
return prompt_for_command_approval_with_amendment(execpolicy_amendment);
|
||||
}
|
||||
|
||||
if prompt_for_yes_no("Approve command execution request? [y/n] ")? {
|
||||
Ok(CommandExecutionApprovalDecision::Accept)
|
||||
} else {
|
||||
Ok(CommandExecutionApprovalDecision::Decline)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_change_approval_decision(&self) -> Result<FileChangeApprovalDecision> {
|
||||
if prompt_for_yes_no("Approve file-change request? [y/n] ")? {
|
||||
Ok(FileChangeApprovalDecision::Accept)
|
||||
} else {
|
||||
Ok(FileChangeApprovalDecision::Decline)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_server_request_response<T>(&mut self, request_id: RequestId, response: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
@@ -1644,6 +1769,59 @@ fn print_multiline_with_prefix(prefix: &str, payload: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_for_yes_no(prompt: &str) -> Result<bool> {
|
||||
loop {
|
||||
print!("{prompt}");
|
||||
io::stdout()
|
||||
.flush()
|
||||
.context("failed to flush approval prompt")?;
|
||||
|
||||
let mut line = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut line)
|
||||
.context("failed to read approval input")?;
|
||||
let input = line.trim().to_ascii_lowercase();
|
||||
if matches!(input.as_str(), "y" | "yes") {
|
||||
return Ok(true);
|
||||
}
|
||||
if matches!(input.as_str(), "n" | "no") {
|
||||
return Ok(false);
|
||||
}
|
||||
println!("please answer y or n");
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_for_command_approval_with_amendment(
|
||||
execpolicy_amendment: ExecPolicyAmendment,
|
||||
) -> Result<CommandExecutionApprovalDecision> {
|
||||
loop {
|
||||
print!("Approve command execution request? [y/n/a] (a=always allow) ");
|
||||
io::stdout()
|
||||
.flush()
|
||||
.context("failed to flush approval prompt")?;
|
||||
|
||||
let mut line = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut line)
|
||||
.context("failed to read approval input")?;
|
||||
let input = line.trim().to_ascii_lowercase();
|
||||
if matches!(input.as_str(), "y" | "yes") {
|
||||
return Ok(CommandExecutionApprovalDecision::Accept);
|
||||
}
|
||||
if matches!(input.as_str(), "n" | "no") {
|
||||
return Ok(CommandExecutionApprovalDecision::Decline);
|
||||
}
|
||||
if matches!(input.as_str(), "a" | "always" | "always allow") {
|
||||
return Ok(
|
||||
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
||||
execpolicy_amendment: execpolicy_amendment.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
println!("please answer y, n, or a");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CodexClient {
|
||||
fn drop(&mut self) {
|
||||
let ClientTransport::Stdio { child, stdin, .. } = &mut self.transport else {
|
||||
|
||||
@@ -225,6 +225,7 @@ pub async fn process_exec_tool_call(
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
})
|
||||
.map_err(CodexErr::from)?;
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ Build platform wrappers and produce ExecRequest for execution. Owns low-level
|
||||
sandbox placement and transformation of portable CommandSpec into a
|
||||
ready‑to‑spawn environment.
|
||||
*/
|
||||
mod policy_merge;
|
||||
pub(crate) use policy_merge::extend_sandbox_policy;
|
||||
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
@@ -17,7 +19,9 @@ use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::create_seatbelt_command_args;
|
||||
use crate::seatbelt::create_seatbelt_command_args_with_extensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
@@ -25,6 +29,8 @@ use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
pub use codex_protocol::models::SandboxPermissions;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) type MacOsSeatbeltProfileExtensions = ();
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -70,6 +76,7 @@ pub(crate) struct SandboxTransformRequest<'a> {
|
||||
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
|
||||
pub use_linux_sandbox_bwrap: bool,
|
||||
pub windows_sandbox_level: WindowsSandboxLevel,
|
||||
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
pub enum SandboxPreference {
|
||||
@@ -145,6 +152,7 @@ impl SandboxManager {
|
||||
codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level,
|
||||
macos_seatbelt_profile_extensions,
|
||||
} = request;
|
||||
let mut env = spec.env;
|
||||
if !policy.has_full_network_access() {
|
||||
@@ -170,12 +178,13 @@ impl SandboxManager {
|
||||
let zsh_exec_bridge_allowed_unix_sockets = zsh_exec_bridge_wrapper_socket
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |path| vec![path.clone()]);
|
||||
let mut args = create_seatbelt_command_args(
|
||||
let mut args = create_seatbelt_command_args_with_extensions(
|
||||
command.clone(),
|
||||
policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
macos_seatbelt_profile_extensions,
|
||||
&zsh_exec_bridge_allowed_unix_sockets,
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
@@ -247,12 +256,22 @@ pub async fn execute_env(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::CommandSpec;
|
||||
use super::SandboxManager;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::SandboxTransformRequest;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
|
||||
@@ -278,4 +297,66 @@ mod tests {
|
||||
);
|
||||
assert_eq!(sandbox, expected);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn transform_applies_macos_seatbelt_profile_extensions_when_present() {
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use crate::seatbelt_permissions::MacOsAutomationPermission;
|
||||
use crate::seatbelt_permissions::MacOsPreferencesPermission;
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
|
||||
let manager = SandboxManager::new();
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempdir.path().to_path_buf();
|
||||
let spec = CommandSpec {
|
||||
program: "/bin/echo".to_string(),
|
||||
args: vec!["ok".to_string()],
|
||||
cwd: cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
expiration: ExecExpiration::DefaultTimeout,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
};
|
||||
let extensions = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: false,
|
||||
};
|
||||
|
||||
let transformed = manager
|
||||
.transform(SandboxTransformRequest {
|
||||
spec,
|
||||
policy: &SandboxPolicy::new_read_only_policy(),
|
||||
sandbox: SandboxType::MacosSeatbelt,
|
||||
enforce_managed_network: false,
|
||||
network: None,
|
||||
sandbox_policy_cwd: &cwd,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
macos_seatbelt_profile_extensions: Some(&extensions),
|
||||
})
|
||||
.expect("transform");
|
||||
|
||||
assert_eq!(
|
||||
transformed.command.first(),
|
||||
Some(&MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string())
|
||||
);
|
||||
let policy_arg_idx = transformed
|
||||
.command
|
||||
.iter()
|
||||
.position(|arg| arg == "-p")
|
||||
.expect("contains -p policy");
|
||||
let policy = transformed
|
||||
.command
|
||||
.get(policy_arg_idx + 1)
|
||||
.expect("policy after -p");
|
||||
assert!(policy.contains("(allow user-preference-write)"));
|
||||
assert!(policy.contains("com.apple.Notes"));
|
||||
assert!(policy.contains("com.apple.axserver"));
|
||||
}
|
||||
}
|
||||
|
||||
396
codex-rs/core/src/sandboxing/policy_merge.rs
Normal file
396
codex-rs/core/src/sandboxing/policy_merge.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
pub(crate) fn extend_sandbox_policy(
|
||||
base: &SandboxPolicy,
|
||||
extension: &SandboxPolicy,
|
||||
) -> SandboxPolicy {
|
||||
// Merge by intersection of capabilities: the combined policy must satisfy
|
||||
// restrictions from both `base` and `extension`.
|
||||
match (base, extension) {
|
||||
(SandboxPolicy::DangerFullAccess, other) | (other, SandboxPolicy::DangerFullAccess) => {
|
||||
other.clone()
|
||||
}
|
||||
(
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: base_network,
|
||||
},
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: extension_network,
|
||||
},
|
||||
) => SandboxPolicy::ExternalSandbox {
|
||||
network_access: restrict_network_access(*base_network, *extension_network),
|
||||
},
|
||||
(SandboxPolicy::ExternalSandbox { .. }, SandboxPolicy::ReadOnly { access })
|
||||
| (SandboxPolicy::ReadOnly { access }, SandboxPolicy::ExternalSandbox { .. }) => {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: access.clone(),
|
||||
}
|
||||
}
|
||||
(
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: external_network_access,
|
||||
},
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
read_only_access,
|
||||
network_access,
|
||||
exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp,
|
||||
},
|
||||
)
|
||||
| (
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
read_only_access,
|
||||
network_access,
|
||||
exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp,
|
||||
},
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: external_network_access,
|
||||
},
|
||||
) => SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots.clone(),
|
||||
read_only_access: read_only_access.clone(),
|
||||
network_access: *network_access && external_network_access.is_enabled(),
|
||||
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp: *exclude_slash_tmp,
|
||||
},
|
||||
(
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: base_access,
|
||||
},
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: extension_access,
|
||||
},
|
||||
) => SandboxPolicy::ReadOnly {
|
||||
access: intersect_read_only_access(base_access, extension_access),
|
||||
},
|
||||
(
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: base_access,
|
||||
},
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
read_only_access, ..
|
||||
},
|
||||
) => SandboxPolicy::ReadOnly {
|
||||
access: intersect_read_only_access(base_access, read_only_access),
|
||||
},
|
||||
(
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
read_only_access, ..
|
||||
},
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: extension_access,
|
||||
},
|
||||
) => SandboxPolicy::ReadOnly {
|
||||
access: intersect_read_only_access(read_only_access, extension_access),
|
||||
},
|
||||
(
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: base_writable_roots,
|
||||
read_only_access: base_read_only_access,
|
||||
network_access: base_network_access,
|
||||
exclude_tmpdir_env_var: base_exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp: base_exclude_slash_tmp,
|
||||
},
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: extension_writable_roots,
|
||||
read_only_access: extension_read_only_access,
|
||||
network_access: extension_network_access,
|
||||
exclude_tmpdir_env_var: extension_exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp: extension_exclude_slash_tmp,
|
||||
},
|
||||
) => SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: intersect_absolute_roots(base_writable_roots, extension_writable_roots),
|
||||
read_only_access: intersect_read_only_access(
|
||||
base_read_only_access,
|
||||
extension_read_only_access,
|
||||
),
|
||||
network_access: *base_network_access && *extension_network_access,
|
||||
exclude_tmpdir_env_var: *base_exclude_tmpdir_env_var
|
||||
|| *extension_exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp: *base_exclude_slash_tmp || *extension_exclude_slash_tmp,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict_network_access(base: NetworkAccess, extension: NetworkAccess) -> NetworkAccess {
|
||||
if base.is_enabled() && extension.is_enabled() {
|
||||
NetworkAccess::Enabled
|
||||
} else {
|
||||
NetworkAccess::Restricted
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_read_only_access(base: &ReadOnlyAccess, extension: &ReadOnlyAccess) -> ReadOnlyAccess {
|
||||
match (base, extension) {
|
||||
(ReadOnlyAccess::FullAccess, access) | (access, ReadOnlyAccess::FullAccess) => {
|
||||
access.clone()
|
||||
}
|
||||
(
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: base_include_platform_defaults,
|
||||
readable_roots: base_readable_roots,
|
||||
},
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: extension_include_platform_defaults,
|
||||
readable_roots: extension_readable_roots,
|
||||
},
|
||||
) => ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: *base_include_platform_defaults
|
||||
&& *extension_include_platform_defaults,
|
||||
readable_roots: intersect_absolute_roots(base_readable_roots, extension_readable_roots),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_absolute_roots(
|
||||
base_roots: &[AbsolutePathBuf],
|
||||
extension_roots: &[AbsolutePathBuf],
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let extension_roots_set: HashSet<_> = extension_roots
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf)
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for root in base_roots {
|
||||
let root_path = root.to_path_buf();
|
||||
if extension_roots_set.contains(&root_path) && seen.insert(root_path) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extend_sandbox_policy;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_combines_read_only_and_workspace_write_as_read_only() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let base_read_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("base-read")).expect("absolute path");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: vec![base_read_root],
|
||||
},
|
||||
},
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_uses_extension_when_base_is_danger_full_access() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let extension_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("extension")).expect("absolute path");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![extension_root.clone()],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![extension_root],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_external_and_workspace_write_keeps_workspace_write_restrictions() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let workspace_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("workspace")).expect("absolute path");
|
||||
let read_root = AbsolutePathBuf::try_from(tempdir.path().join("read")).expect("absolute");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
},
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![workspace_root.clone()],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![read_root.clone()],
|
||||
},
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![workspace_root],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![read_root],
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_external_and_read_only_returns_read_only() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let read_root = AbsolutePathBuf::try_from(tempdir.path().join("read")).expect("absolute");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
},
|
||||
&SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![read_root.clone()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![read_root],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_intersects_workspace_roots_and_restricts_network_access() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let shared_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("shared")).expect("absolute path");
|
||||
let base_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("base")).expect("absolute path");
|
||||
let extension_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("extension")).expect("absolute path");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![shared_root.clone(), base_root],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![shared_root.clone(), extension_root.clone()],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: vec![extension_root.clone()],
|
||||
},
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![shared_root],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: vec![extension_root],
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_sandbox_policy_keeps_network_access_enabled_only_when_both_policies_enable_it() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let shared_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("shared")).expect("absolute path");
|
||||
let base_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("base")).expect("absolute path");
|
||||
let extension_root =
|
||||
AbsolutePathBuf::try_from(tempdir.path().join("extension")).expect("absolute path");
|
||||
|
||||
let merged = extend_sandbox_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![base_root, shared_root.clone()],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![shared_root.clone(), extension_root],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![shared_root],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,85 @@ impl MacOsSeatbeltProfileExtensions {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_macos_seatbelt_profile_extensions(
|
||||
base: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
extension: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
match (base, extension) {
|
||||
(None, None) => None,
|
||||
(Some(base), None) => Some(base.clone().normalized()),
|
||||
(None, Some(extension)) => Some(extension.clone().normalized()),
|
||||
(Some(base), Some(extension)) => {
|
||||
let base = base.normalized();
|
||||
let extension = extension.normalized();
|
||||
Some(
|
||||
MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: merge_macos_preferences_permission(
|
||||
&base.macos_preferences,
|
||||
&extension.macos_preferences,
|
||||
),
|
||||
macos_automation: merge_macos_automation_permission(
|
||||
&base.macos_automation,
|
||||
&extension.macos_automation,
|
||||
),
|
||||
macos_accessibility: base.macos_accessibility && extension.macos_accessibility,
|
||||
macos_calendar: base.macos_calendar && extension.macos_calendar,
|
||||
}
|
||||
.normalized(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_macos_preferences_permission(
|
||||
base: &MacOsPreferencesPermission,
|
||||
extension: &MacOsPreferencesPermission,
|
||||
) -> MacOsPreferencesPermission {
|
||||
fn rank(permission: &MacOsPreferencesPermission) -> u8 {
|
||||
match permission {
|
||||
MacOsPreferencesPermission::None => 0,
|
||||
MacOsPreferencesPermission::ReadOnly => 1,
|
||||
MacOsPreferencesPermission::ReadWrite => 2,
|
||||
}
|
||||
}
|
||||
|
||||
if rank(extension) < rank(base) {
|
||||
extension.clone()
|
||||
} else {
|
||||
base.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_macos_automation_permission(
|
||||
base: &MacOsAutomationPermission,
|
||||
extension: &MacOsAutomationPermission,
|
||||
) -> MacOsAutomationPermission {
|
||||
match (base, extension) {
|
||||
(MacOsAutomationPermission::None, _) | (_, MacOsAutomationPermission::None) => {
|
||||
MacOsAutomationPermission::None
|
||||
}
|
||||
(MacOsAutomationPermission::All, other) | (other, MacOsAutomationPermission::All) => {
|
||||
other.clone()
|
||||
}
|
||||
(
|
||||
MacOsAutomationPermission::BundleIds(base_ids),
|
||||
MacOsAutomationPermission::BundleIds(extension_ids),
|
||||
) => {
|
||||
let base_ids = base_ids.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let extension_ids = extension_ids.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let intersection = base_ids
|
||||
.intersection(&extension_ids)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if intersection.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(intersection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_seatbelt_extensions(
|
||||
extensions: &MacOsSeatbeltProfileExtensions,
|
||||
) -> SeatbeltExtensionPolicy {
|
||||
@@ -166,6 +245,7 @@ mod tests {
|
||||
use super::MacOsPreferencesPermission;
|
||||
use super::MacOsSeatbeltProfileExtensions;
|
||||
use super::build_seatbelt_extensions;
|
||||
use super::merge_macos_seatbelt_profile_extensions;
|
||||
|
||||
#[test]
|
||||
fn preferences_read_only_emits_read_clauses_only() {
|
||||
@@ -245,4 +325,95 @@ mod tests {
|
||||
assert!(policy.policy.contains("(allow user-preference-read)"));
|
||||
assert!(!policy.policy.contains("(allow user-preference-write)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_extensions_intersects_permissions() {
|
||||
let base = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_accessibility: false,
|
||||
macos_calendar: true,
|
||||
};
|
||||
let extension = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Reminders".to_string(),
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: false,
|
||||
};
|
||||
|
||||
let merged =
|
||||
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
|
||||
|
||||
assert_eq!(
|
||||
merged.macos_preferences,
|
||||
MacOsPreferencesPermission::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
merged.macos_automation,
|
||||
MacOsAutomationPermission::BundleIds(vec!["com.apple.Calendar".to_string(),])
|
||||
);
|
||||
assert!(!merged.macos_accessibility);
|
||||
assert!(!merged.macos_calendar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_extensions_all_intersects_to_other_side() {
|
||||
let base = MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::All,
|
||||
..Default::default()
|
||||
};
|
||||
let extension = MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged =
|
||||
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
|
||||
assert_eq!(
|
||||
merged.macos_automation,
|
||||
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string(),])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_extensions_none_intersects_to_none() {
|
||||
let base = MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::All,
|
||||
..Default::default()
|
||||
};
|
||||
let extension = MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged =
|
||||
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&extension)).expect("merged");
|
||||
assert_eq!(merged.macos_automation, MacOsAutomationPermission::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_extensions_normalizes_single_source() {
|
||||
let extension = MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
" com.apple.Notes ".to_string(),
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged =
|
||||
merge_macos_seatbelt_profile_extensions(None, Some(&extension)).expect("merged");
|
||||
assert_eq!(
|
||||
merged.macos_automation,
|
||||
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
521
codex-rs/core/src/skills/command_policy.rs
Normal file
521
codex-rs/core/src/skills/command_policy.rs
Normal file
@@ -0,0 +1,521 @@
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
|
||||
use crate::config::Permissions;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
|
||||
/// Resolves the full permissions extension contributed by the first matching
|
||||
/// skill for a command invocation.
|
||||
///
|
||||
/// Assumptions:
|
||||
/// 1. Skill policy extension is enabled only when `shell_zsh_fork` is enabled.
|
||||
/// 2. `command` contains the executable and arguments for the invocation.
|
||||
/// 3. `command_cwd` reflects the effective command target location.
|
||||
/// 4. Only commands executed via `zsh` are eligible.
|
||||
/// 5. Only executable paths under `<skill>/scripts` are eligible.
|
||||
///
|
||||
/// Returns `None` when `shell_zsh_fork` is disabled, command shell is not
|
||||
/// `zsh`, or when no enabled skill with permissions matches an eligible
|
||||
/// command action executable.
|
||||
pub(crate) fn resolve_skill_permissions_for_command(
|
||||
skills_outcome: &SkillLoadOutcome,
|
||||
shell_zsh_fork_enabled: bool,
|
||||
command: &[String],
|
||||
command_cwd: &Path,
|
||||
command_actions: &[ParsedCommand],
|
||||
) -> Option<Permissions> {
|
||||
resolve_skill_permissions_ref(
|
||||
skills_outcome,
|
||||
shell_zsh_fork_enabled,
|
||||
command,
|
||||
command_cwd,
|
||||
command_actions,
|
||||
)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn resolve_skill_permissions_ref<'a>(
|
||||
skills_outcome: &'a SkillLoadOutcome,
|
||||
shell_zsh_fork_enabled: bool,
|
||||
command: &[String],
|
||||
command_cwd: &Path,
|
||||
command_actions: &[ParsedCommand],
|
||||
) -> Option<&'a Permissions> {
|
||||
if !shell_zsh_fork_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command_executable_name = command
|
||||
.first()
|
||||
.and_then(|program| Path::new(program).file_name())
|
||||
.and_then(|name| name.to_str());
|
||||
if command_executable_name != Some("zsh") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for command_action in command_actions {
|
||||
let Some(action_candidate) = command_action_candidate_path(command_action, command_cwd)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(permissions) = match_skill_for_candidate(skills_outcome, &action_candidate) {
|
||||
return Some(permissions);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn command_action_candidate_path(
|
||||
command_action: &ParsedCommand,
|
||||
command_cwd: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let action_path = match command_action {
|
||||
ParsedCommand::Unknown { cmd } => {
|
||||
let tokens = shlex::split(cmd)?;
|
||||
let executable = tokens.first()?;
|
||||
let executable_path = PathBuf::from(executable);
|
||||
if !executable_path.is_absolute() && !executable.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some(executable_path)
|
||||
}
|
||||
ParsedCommand::Read { .. }
|
||||
| ParsedCommand::ListFiles { .. }
|
||||
| ParsedCommand::Search { .. } => None,
|
||||
}?;
|
||||
let action_path = if action_path.is_absolute() {
|
||||
action_path
|
||||
} else {
|
||||
command_cwd.join(action_path)
|
||||
};
|
||||
normalize_candidate_path(action_path.as_path())
|
||||
}
|
||||
|
||||
fn normalize_candidate_path(path: &Path) -> Option<PathBuf> {
|
||||
let normalized = normalize_lexically(path);
|
||||
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
|
||||
let comparison_path =
|
||||
normalize_for_path_comparison(canonicalized.as_path()).unwrap_or(canonicalized);
|
||||
if comparison_path.is_absolute() {
|
||||
Some(comparison_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn match_skill_for_candidate<'a>(
|
||||
skills_outcome: &'a SkillLoadOutcome,
|
||||
candidate: &Path,
|
||||
) -> Option<&'a Permissions> {
|
||||
for skill in &skills_outcome.skills {
|
||||
// Disabled skills must not contribute sandbox policy extensions.
|
||||
if skills_outcome.disabled_paths.contains(&skill.path) {
|
||||
continue;
|
||||
}
|
||||
// Skills without a permissions block cannot supply sandbox policy.
|
||||
let Some(permissions) = skill.permissions.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// Match against the containing skill directory, not the SKILL.md file.
|
||||
let Some(skill_dir) = skill.path.parent() else {
|
||||
continue;
|
||||
};
|
||||
let skill_scripts_dir = skill_dir.join("scripts");
|
||||
// Normalize before comparing so path containment checks are stable.
|
||||
let Some(skill_scripts_dir) = normalize_candidate_path(&skill_scripts_dir) else {
|
||||
continue;
|
||||
};
|
||||
// The executable must live inside the skill's scripts directory.
|
||||
if !candidate.starts_with(&skill_scripts_dir) {
|
||||
continue;
|
||||
}
|
||||
return Some(permissions);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_skill_permissions_for_command;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::Permissions;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn skill_with_policy(skill_path: PathBuf, sandbox_policy: SandboxPolicy) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "skill".to_string(),
|
||||
description: "skill".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permissions: Some(Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(sandbox_policy),
|
||||
network: None,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}),
|
||||
path: skill_path,
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_with_skills(skills: Vec<SkillMetadata>) -> SkillLoadOutcome {
|
||||
SkillLoadOutcome {
|
||||
skills,
|
||||
errors: Vec::new(),
|
||||
disabled_paths: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn resolve_skill_sandbox_extension_for_command(
|
||||
skills_outcome: &SkillLoadOutcome,
|
||||
shell_zsh_fork_enabled: bool,
|
||||
command: &[String],
|
||||
command_cwd: &Path,
|
||||
command_actions: &[ParsedCommand],
|
||||
) -> Option<SandboxPolicy> {
|
||||
resolve_skill_permissions_for_command(
|
||||
skills_outcome,
|
||||
shell_zsh_fork_enabled,
|
||||
command,
|
||||
command_cwd,
|
||||
command_actions,
|
||||
)
|
||||
.map(|permissions| permissions.sandbox_policy.get().clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_policy_for_zsh_executable_inside_skill_scripts_directory() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("create scripts");
|
||||
std::fs::write(scripts_dir.join("run.sh"), "#!/bin/sh\necho ok\n").expect("write script");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
let cwd = tempdir.path().to_path_buf();
|
||||
|
||||
let write_root = AbsolutePathBuf::try_from(skill_dir.join("output")).expect("absolute");
|
||||
let skill_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![write_root],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
canonical(&skill_path),
|
||||
skill_policy.clone(),
|
||||
)]);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/demo/scripts/run.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/demo/scripts/run.sh".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
&cwd,
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, Some(skill_policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_resolve_policy_when_command_is_not_zsh() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("create scripts");
|
||||
std::fs::write(scripts_dir.join("run.sh"), "#!/bin/sh\necho ok\n").expect("write script");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
|
||||
let skill_policy = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(skill_dir.join("data")).expect("absolute"),
|
||||
],
|
||||
},
|
||||
};
|
||||
let outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
canonical(&skill_path),
|
||||
skill_policy,
|
||||
)]);
|
||||
let command = vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/demo/scripts/run.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/demo/scripts/run.sh".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
tempdir.path(),
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_resolve_policy_for_paths_outside_skill_scripts_directory() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
std::fs::create_dir_all(&skill_dir).expect("create skill");
|
||||
std::fs::write(skill_dir.join("run.sh"), "#!/bin/sh\necho ok\n").expect("write script");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
|
||||
let skill_policy = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(skill_dir.join("data")).expect("absolute"),
|
||||
],
|
||||
},
|
||||
};
|
||||
let outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
canonical(&skill_path),
|
||||
skill_policy,
|
||||
)]);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/demo/run.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/demo/run.sh".to_string(),
|
||||
}];
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
tempdir.path(),
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_disabled_skill_when_resolving_command_policy() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("create skill dir");
|
||||
std::fs::write(scripts_dir.join("tool.sh"), "#!/bin/sh\necho ok\n").expect("write script");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
let skill_path = canonical(&skill_path);
|
||||
|
||||
let mut outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
skill_path.clone(),
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
)]);
|
||||
outcome.disabled_paths.insert(skill_path);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/demo/scripts/tool.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/demo/scripts/tool.sh".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
tempdir.path(),
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_nested_skill_scripts_path() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let parent_skill_dir = tempdir.path().join("skills/parent");
|
||||
let nested_skill_dir = parent_skill_dir.join("nested");
|
||||
std::fs::create_dir_all(parent_skill_dir.join("scripts")).expect("create parent scripts");
|
||||
std::fs::create_dir_all(nested_skill_dir.join("scripts")).expect("create nested scripts");
|
||||
std::fs::write(
|
||||
parent_skill_dir.join("scripts/run.sh"),
|
||||
"#!/bin/sh\necho parent\n",
|
||||
)
|
||||
.expect("write script");
|
||||
|
||||
std::fs::write(
|
||||
nested_skill_dir.join("scripts/run.sh"),
|
||||
"#!/bin/sh\necho nested\n",
|
||||
)
|
||||
.expect("write script");
|
||||
|
||||
let parent_skill_path = parent_skill_dir.join("SKILL.md");
|
||||
let nested_skill_path = nested_skill_dir.join("SKILL.md");
|
||||
std::fs::write(&parent_skill_path, "parent").expect("write parent skill");
|
||||
std::fs::write(&nested_skill_path, "nested").expect("write nested skill");
|
||||
|
||||
let parent_policy = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(parent_skill_dir.join("data")).expect("absolute"),
|
||||
],
|
||||
},
|
||||
};
|
||||
let nested_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![
|
||||
AbsolutePathBuf::try_from(nested_skill_dir.join("output")).expect("absolute"),
|
||||
],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let outcome = outcome_with_skills(vec![
|
||||
skill_with_policy(canonical(&parent_skill_path), parent_policy),
|
||||
skill_with_policy(canonical(&nested_skill_path), nested_policy.clone()),
|
||||
]);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/parent/nested/scripts/run.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/parent/nested/scripts/run.sh".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
tempdir.path(),
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, Some(nested_policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_path_unknown_command_actions() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
std::fs::create_dir_all(skill_dir.join("scripts")).expect("create scripts");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
|
||||
let outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
canonical(&skill_path),
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
)]);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo hi".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "echo hi".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
true,
|
||||
&command,
|
||||
skill_dir.join("scripts").as_path(),
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_resolve_policy_when_shell_zsh_fork_is_disabled() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skills/demo");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("create scripts");
|
||||
std::fs::write(scripts_dir.join("run.sh"), "#!/bin/sh\necho ok\n").expect("write script");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "skill").expect("write SKILL.md");
|
||||
let cwd = tempdir.path().to_path_buf();
|
||||
|
||||
let outcome = outcome_with_skills(vec![skill_with_policy(
|
||||
canonical(&skill_path),
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
)]);
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"skills/demo/scripts/run.sh".to_string(),
|
||||
];
|
||||
let command_actions = vec![ParsedCommand::Unknown {
|
||||
cmd: "skills/demo/scripts/run.sh".to_string(),
|
||||
}];
|
||||
|
||||
let resolved = resolve_skill_sandbox_extension_for_command(
|
||||
&outcome,
|
||||
false,
|
||||
&command,
|
||||
&cwd,
|
||||
&command_actions,
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod command_policy;
|
||||
mod env_var_dependencies;
|
||||
pub mod injection;
|
||||
pub mod loader;
|
||||
@@ -8,6 +9,7 @@ pub mod remote;
|
||||
pub mod render;
|
||||
pub mod system;
|
||||
|
||||
pub(crate) use command_policy::resolve_skill_permissions_for_command;
|
||||
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
|
||||
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use injection::SkillInjections;
|
||||
|
||||
@@ -9,10 +9,18 @@ use crate::codex::TurnContext;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::sandboxing::extend_sandbox_policy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::merge_macos_seatbelt_profile_extensions;
|
||||
use crate::shell::Shell;
|
||||
use crate::skills::resolve_skill_permissions_for_command;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -26,6 +34,8 @@ use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
type MacOsSeatbeltProfileExtensions = ();
|
||||
|
||||
pub struct ShellHandler;
|
||||
|
||||
@@ -297,13 +307,44 @@ impl ShellHandler {
|
||||
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let skills_outcome = session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&turn.cwd, false)
|
||||
.await;
|
||||
let shell_zsh_fork_enabled = session.features().enabled(Feature::ShellZshFork);
|
||||
let command_actions = parse_command(&exec_params.command);
|
||||
let skill_permissions = resolve_skill_permissions_for_command(
|
||||
&skills_outcome,
|
||||
shell_zsh_fork_enabled,
|
||||
&exec_params.command,
|
||||
&exec_params.cwd,
|
||||
&command_actions,
|
||||
);
|
||||
let effective_sandbox_policy = skill_permissions.as_ref().map_or_else(
|
||||
|| turn.sandbox_policy.clone(),
|
||||
|skill_permissions| {
|
||||
extend_sandbox_policy(&turn.sandbox_policy, skill_permissions.sandbox_policy.get())
|
||||
},
|
||||
);
|
||||
let effective_macos_seatbelt_profile_extensions =
|
||||
merge_turn_and_skill_macos_seatbelt_profile_extensions(
|
||||
turn.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
skill_permissions
|
||||
.as_ref()
|
||||
.and_then(|permissions| permissions.macos_seatbelt_profile_extensions.as_ref()),
|
||||
);
|
||||
|
||||
let exec_approval_requirement = session
|
||||
.services
|
||||
.exec_policy
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &exec_params.command,
|
||||
approval_policy: turn.approval_policy,
|
||||
sandbox_policy: &turn.sandbox_policy,
|
||||
sandbox_policy: &effective_sandbox_policy,
|
||||
sandbox_permissions: exec_params.sandbox_permissions,
|
||||
prefix_rule,
|
||||
})
|
||||
@@ -318,6 +359,7 @@ impl ShellHandler {
|
||||
network: exec_params.network.clone(),
|
||||
sandbox_permissions: exec_params.sandbox_permissions,
|
||||
justification: exec_params.justification.clone(),
|
||||
macos_seatbelt_profile_extensions: effective_macos_seatbelt_profile_extensions,
|
||||
exec_approval_requirement,
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
@@ -330,7 +372,14 @@ impl ShellHandler {
|
||||
network_attempt_id: None,
|
||||
};
|
||||
let out = orchestrator
|
||||
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
|
||||
.run_with_sandbox_policy(
|
||||
&mut runtime,
|
||||
&req,
|
||||
&tool_ctx,
|
||||
&turn,
|
||||
turn.approval_policy,
|
||||
&effective_sandbox_policy,
|
||||
)
|
||||
.await
|
||||
.map(|result| result.output);
|
||||
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
|
||||
@@ -342,6 +391,22 @@ impl ShellHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn merge_turn_and_skill_macos_seatbelt_profile_extensions(
|
||||
turn_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
skill_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
merge_macos_seatbelt_profile_extensions(turn_extensions, skill_extensions)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn merge_turn_and_skill_macos_seatbelt_profile_extensions(
|
||||
_turn_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
_skill_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -617,6 +617,7 @@ impl JsReplManager {
|
||||
.features
|
||||
.enabled(crate::features::Feature::UseLinuxSandboxBwrap),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
})
|
||||
.map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?;
|
||||
|
||||
|
||||
@@ -107,6 +107,29 @@ impl ToolOrchestrator {
|
||||
turn_ctx: &crate::codex::TurnContext,
|
||||
approval_policy: AskForApproval,
|
||||
) -> Result<OrchestratorRunResult<Out>, ToolError>
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
self.run_with_sandbox_policy(
|
||||
tool,
|
||||
req,
|
||||
tool_ctx,
|
||||
turn_ctx,
|
||||
approval_policy,
|
||||
&turn_ctx.sandbox_policy,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_with_sandbox_policy<Rq, Out, T>(
|
||||
&mut self,
|
||||
tool: &mut T,
|
||||
req: &Rq,
|
||||
tool_ctx: &ToolCtx<'_>,
|
||||
turn_ctx: &crate::codex::TurnContext,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &crate::protocol::SandboxPolicy,
|
||||
) -> Result<OrchestratorRunResult<Out>, ToolError>
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
@@ -119,9 +142,9 @@ impl ToolOrchestrator {
|
||||
// 1) Approval
|
||||
let mut already_approved = false;
|
||||
|
||||
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
|
||||
default_exec_approval_requirement(approval_policy, &turn_ctx.sandbox_policy)
|
||||
});
|
||||
let requirement = tool
|
||||
.exec_approval_requirement(req)
|
||||
.unwrap_or_else(|| default_exec_approval_requirement(approval_policy, sandbox_policy));
|
||||
match requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => {
|
||||
otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg);
|
||||
@@ -163,7 +186,7 @@ impl ToolOrchestrator {
|
||||
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
|
||||
SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None,
|
||||
SandboxOverride::NoOverride => self.sandbox.select_initial(
|
||||
&turn_ctx.sandbox_policy,
|
||||
sandbox_policy,
|
||||
tool.sandbox_preference(),
|
||||
turn_ctx.windows_sandbox_level,
|
||||
has_managed_network_requirements,
|
||||
@@ -175,7 +198,7 @@ impl ToolOrchestrator {
|
||||
let use_linux_sandbox_bwrap = turn_ctx.features.enabled(Feature::UseLinuxSandboxBwrap);
|
||||
let initial_attempt = SandboxAttempt {
|
||||
sandbox: initial_sandbox,
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
policy: sandbox_policy,
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
@@ -230,10 +253,7 @@ impl ToolOrchestrator {
|
||||
matches!(approval_policy, AskForApproval::OnRequest)
|
||||
&& network_approval_context.is_some()
|
||||
&& matches!(
|
||||
default_exec_approval_requirement(
|
||||
approval_policy,
|
||||
&turn_ctx.sandbox_policy
|
||||
),
|
||||
default_exec_approval_requirement(approval_policy, sandbox_policy),
|
||||
ExecApprovalRequirement::NeedsApproval { .. }
|
||||
);
|
||||
if !allow_on_request_network_prompt {
|
||||
@@ -281,7 +301,7 @@ impl ToolOrchestrator {
|
||||
|
||||
let escalated_attempt = SandboxAttempt {
|
||||
sandbox: crate::exec::SandboxType::None,
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
policy: sandbox_policy,
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
|
||||
@@ -151,7 +151,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let spec = Self::build_command_spec(req)?;
|
||||
let env = attempt
|
||||
.env_for(spec, None)
|
||||
.env_for(spec, None, None)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
|
||||
.await
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::features::Feature;
|
||||
use crate::powershell::prefix_powershell_script_with_utf8;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::execute_env;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::tools::network_approval::NetworkApprovalMode;
|
||||
use crate::tools::network_approval::NetworkApprovalSpec;
|
||||
@@ -31,6 +33,8 @@ use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::future::BoxFuture;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
type MacOsSeatbeltProfileExtensions = ();
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShellRequest {
|
||||
@@ -42,6 +46,7 @@ pub struct ShellRequest {
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub justification: Option<String>,
|
||||
pub macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
@@ -203,7 +208,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
req.justification.clone(),
|
||||
)?;
|
||||
let env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.env_for(
|
||||
spec,
|
||||
req.network.as_ref(),
|
||||
req.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
return ctx
|
||||
.session
|
||||
@@ -222,7 +231,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
req.justification.clone(),
|
||||
)?;
|
||||
let mut env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.env_for(
|
||||
spec,
|
||||
req.network.as_ref(),
|
||||
req.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
env.network_attempt_id = ctx.network_attempt_id.clone();
|
||||
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
|
||||
|
||||
@@ -200,7 +200,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
)
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
let exec_env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.env_for(spec, req.network.as_ref(), None)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
self.manager
|
||||
.open_session_with_exec_env(&exec_env, req.tty)
|
||||
|
||||
@@ -293,6 +293,9 @@ impl<'a> SandboxAttempt<'a> {
|
||||
&self,
|
||||
spec: CommandSpec,
|
||||
network: Option<&NetworkProxy>,
|
||||
macos_seatbelt_profile_extensions: Option<
|
||||
&crate::sandboxing::MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
|
||||
self.manager
|
||||
.transform(crate::sandboxing::SandboxTransformRequest {
|
||||
@@ -305,6 +308,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
macos_seatbelt_profile_extensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user