mirror of
https://github.com/openai/codex.git
synced 2026-03-05 22:23:22 +00:00
Compare commits
9 Commits
fix/notify
...
dev/cc/ski
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6050aafe | ||
|
|
7c0af91bd2 | ||
|
|
4144aee4eb | ||
|
|
6ebb45bfcb | ||
|
|
2d54e07c98 | ||
|
|
d26641c5e7 | ||
|
|
60ca57e09d | ||
|
|
e83ad68719 | ||
|
|
d0923bb2c4 |
@@ -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,51 @@ 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)) => {
|
||||
let path = fs::canonicalize(&path)
|
||||
.with_context(|| format!("canonicalize --skill-path {}", path.display()))?;
|
||||
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 +1051,7 @@ struct CodexClient {
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CommandApprovalBehavior {
|
||||
Prompt,
|
||||
AlwaysAccept,
|
||||
AbortOn(usize),
|
||||
}
|
||||
@@ -999,7 +1102,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 +1123,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 +1625,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 +1666,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 +1773,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 {
|
||||
|
||||
@@ -419,6 +419,172 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_shell_zsh_fork_subcommand_escalated_requests_approval_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let tmp = TempDir::new()?;
|
||||
let codex_home = tmp.path().join("codex_home");
|
||||
std::fs::create_dir(&codex_home)?;
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path() else {
|
||||
eprintln!("skipping zsh fork subcommand escalated test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
if !supports_exec_wrapper_intercept(&zsh_path) {
|
||||
eprintln!(
|
||||
"skipping zsh fork subcommand escalated test: zsh does not support EXEC_WRAPPER intercepts ({})",
|
||||
zsh_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
|
||||
|
||||
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
|
||||
"command": "touch zsh_fork_escalated.txt",
|
||||
"workdir": serde_json::Value::Null,
|
||||
"timeout_ms": 5000,
|
||||
"sandbox_permissions": "require_escalated",
|
||||
}))?;
|
||||
let response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
"call-zsh-fork-subcommand-escalated",
|
||||
"shell_command",
|
||||
&tool_call_arguments,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let server = create_mock_responses_server_sequence(vec![response]).await;
|
||||
create_config_toml(
|
||||
&codex_home,
|
||||
&server.uri(),
|
||||
"on-request",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
cwd: Some(workspace.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run touch".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest),
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
||||
access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
|
||||
}),
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
|
||||
summary: Some(codex_core::protocol_config_types::ReasoningSummary::Auto),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let mut saw_subcommand_approval = false;
|
||||
for _ in 0..3 {
|
||||
let server_req = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req
|
||||
else {
|
||||
panic!("expected CommandExecutionRequestApproval request");
|
||||
};
|
||||
assert_eq!(params.item_id, "call-zsh-fork-subcommand-escalated");
|
||||
assert_eq!(params.thread_id, thread.id);
|
||||
if params.approval_id.is_some() {
|
||||
saw_subcommand_approval = true;
|
||||
}
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Accept,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
if saw_subcommand_approval {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_subcommand_approval,
|
||||
"expected zsh subcommand approval request with approval_id"
|
||||
);
|
||||
|
||||
let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let completed_notif = mcp
|
||||
.read_stream_until_notification_message("item/completed")
|
||||
.await?;
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(
|
||||
completed_notif
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/completed params"),
|
||||
)?;
|
||||
if let ThreadItem::CommandExecution { id, .. } = &completed.item
|
||||
&& id == "call-zsh-fork-subcommand-escalated"
|
||||
{
|
||||
return Ok::<ThreadItem, anyhow::Error>(completed.item);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let ThreadItem::CommandExecution { id, status, .. } = parent_completed_command_execution else {
|
||||
unreachable!("loop ensures we break on parent command execution item");
|
||||
};
|
||||
assert_eq!(id, "call-zsh-fork-subcommand-escalated");
|
||||
assert_eq!(status, CommandExecutionStatus::Completed);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/task_complete"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(workspace.join("zsh_fork_escalated.txt").is_file());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -120,6 +120,7 @@ use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::Permissions;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::config::resolve_web_search_mode_for_turn;
|
||||
use crate::config::types::McpServerConfig;
|
||||
@@ -216,6 +217,7 @@ use crate::skills::collect_explicit_skill_mentions;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
use crate::skills::permissions::build_skill_script_prefix_permissions;
|
||||
use crate::skills::resolve_skill_dependencies_for_turn;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::SessionServices;
|
||||
@@ -564,6 +566,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) js_repl: Arc<JsReplHandle>,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
pub(crate) skill_prefix_permissions: std::sync::RwLock<HashMap<Vec<String>, Permissions>>,
|
||||
}
|
||||
impl TurnContext {
|
||||
pub(crate) fn model_context_window(&self) -> Option<i64> {
|
||||
@@ -603,6 +606,11 @@ impl TurnContext {
|
||||
let collaboration_mode =
|
||||
self.collaboration_mode
|
||||
.with_updates(Some(model.clone()), Some(reasoning_effort), None);
|
||||
let skill_prefix_permissions = self
|
||||
.skill_prefix_permissions
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
let features = self.features.clone();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -645,9 +653,21 @@ impl TurnContext {
|
||||
js_repl: Arc::clone(&self.js_repl),
|
||||
dynamic_tools: self.dynamic_tools.clone(),
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
skill_prefix_permissions: std::sync::RwLock::new(skill_prefix_permissions),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_skill_prefix_permissions(
|
||||
&self,
|
||||
skill_prefix_permissions: HashMap<Vec<String>, Permissions>,
|
||||
) {
|
||||
let mut registry = self
|
||||
.skill_prefix_permissions
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*registry = skill_prefix_permissions;
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_path(&self, path: Option<String>) -> PathBuf {
|
||||
path.as_ref()
|
||||
.map(PathBuf::from)
|
||||
@@ -987,6 +1007,7 @@ impl Session {
|
||||
js_repl,
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
turn_metadata_state,
|
||||
skill_prefix_permissions: std::sync::RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4177,6 +4198,13 @@ async fn spawn_review_thread(
|
||||
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
turn_metadata_state,
|
||||
skill_prefix_permissions: std::sync::RwLock::new(
|
||||
parent_turn_context
|
||||
.skill_prefix_permissions
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
@@ -4335,6 +4363,8 @@ pub(crate) async fn run_turn(
|
||||
&connector_slug_counts,
|
||||
)
|
||||
});
|
||||
turn_context
|
||||
.set_skill_prefix_permissions(build_skill_script_prefix_permissions(&mentioned_skills));
|
||||
let config = turn_context.config.clone();
|
||||
if config
|
||||
.features
|
||||
|
||||
@@ -162,6 +162,29 @@ impl ExecPolicyManager {
|
||||
&self,
|
||||
req: ExecApprovalRequest<'_>,
|
||||
) -> ExecApprovalRequirement {
|
||||
let exec_policy = self.current();
|
||||
Self::create_exec_approval_requirement_for_command_with_policy(exec_policy.as_ref(), req)
|
||||
}
|
||||
|
||||
/// Evaluates approval requirements with an in-memory prompt-only overlay.
|
||||
///
|
||||
/// The base execpolicy is always evaluated first. If any base policy rule
|
||||
/// matches (allow/prompt/forbidden), that result is returned as-is and the
|
||||
/// overlay is ignored for this command.
|
||||
///
|
||||
/// Overlay prefixes are only applied when the base evaluation had no policy
|
||||
/// matches (i.e. only heuristics would decide). In that case, each overlay
|
||||
/// prefix is added as an in-memory `prompt` rule and the command is
|
||||
/// re-evaluated. The overlay is not persisted to the manager's policy.
|
||||
pub(crate) async fn create_exec_approval_requirement_for_command_with_overlay(
|
||||
&self,
|
||||
req: ExecApprovalRequest<'_>,
|
||||
overlay_prompt_prefixes: &[Vec<String>],
|
||||
) -> ExecApprovalRequirement {
|
||||
if overlay_prompt_prefixes.is_empty() {
|
||||
return self.create_exec_approval_requirement_for_command(req).await;
|
||||
}
|
||||
|
||||
let ExecApprovalRequest {
|
||||
command,
|
||||
approval_policy,
|
||||
@@ -169,64 +192,75 @@ impl ExecPolicyManager {
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
} = req;
|
||||
let exec_policy = self.current();
|
||||
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
|
||||
// Keep heredoc prefix parsing for rule evaluation so existing
|
||||
// allow/prompt/forbidden rules still apply, but avoid auto-derived
|
||||
// amendments when only the heredoc fallback parser matched.
|
||||
let auto_amendment_allowed = !used_complex_parsing;
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
|
||||
|
||||
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
let base_policy = self.current();
|
||||
let (base_evaluation, base_auto_amendment_allowed) = evaluate_exec_policy_for_command(
|
||||
base_policy.as_ref(),
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
sandbox_permissions,
|
||||
);
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: derive_forbidden_reason(command, &evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
if matches!(approval_policy, AskForApproval::Never) {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: PROMPT_CONFLICT_REASON.to_string(),
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
// Bypass sandbox if execpolicy allows the command
|
||||
bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
|
||||
}),
|
||||
proposed_execpolicy_amendment: if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
if base_evaluation.matched_rules.iter().any(is_policy_match) {
|
||||
return create_exec_approval_requirement_from_evaluation(
|
||||
command,
|
||||
approval_policy,
|
||||
prefix_rule.as_ref(),
|
||||
&base_evaluation,
|
||||
base_auto_amendment_allowed,
|
||||
);
|
||||
}
|
||||
|
||||
let mut exec_policy = base_policy.as_ref().clone();
|
||||
for prefix in overlay_prompt_prefixes {
|
||||
if let Err(err) = exec_policy.add_prefix_rule(prefix, Decision::Prompt) {
|
||||
tracing::warn!(
|
||||
"failed to add in-memory execpolicy overlay prompt prefix {prefix:?}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (overlay_evaluation, overlay_auto_amendment_allowed) = evaluate_exec_policy_for_command(
|
||||
&exec_policy,
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
sandbox_permissions,
|
||||
);
|
||||
create_exec_approval_requirement_from_evaluation(
|
||||
command,
|
||||
approval_policy,
|
||||
prefix_rule.as_ref(),
|
||||
&overlay_evaluation,
|
||||
overlay_auto_amendment_allowed,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_exec_approval_requirement_for_command_with_policy(
|
||||
exec_policy: &Policy,
|
||||
req: ExecApprovalRequest<'_>,
|
||||
) -> ExecApprovalRequirement {
|
||||
let ExecApprovalRequest {
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
} = req;
|
||||
let (evaluation, auto_amendment_allowed) = evaluate_exec_policy_for_command(
|
||||
exec_policy,
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
sandbox_permissions,
|
||||
);
|
||||
create_exec_approval_requirement_from_evaluation(
|
||||
command,
|
||||
approval_policy,
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation,
|
||||
auto_amendment_allowed,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn append_amendment_and_update(
|
||||
@@ -255,6 +289,81 @@ impl ExecPolicyManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_exec_policy_for_command(
|
||||
exec_policy: &Policy,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> (Evaluation, bool) {
|
||||
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
|
||||
// Keep heredoc prefix parsing for rule evaluation so existing
|
||||
// allow/prompt/forbidden rules still apply, but avoid auto-derived
|
||||
// amendments when only the heredoc fallback parser matched.
|
||||
let auto_amendment_allowed = !used_complex_parsing;
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
|
||||
(evaluation, auto_amendment_allowed)
|
||||
}
|
||||
|
||||
fn create_exec_approval_requirement_from_evaluation(
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
prefix_rule: Option<&Vec<String>>,
|
||||
evaluation: &Evaluation,
|
||||
auto_amendment_allowed: bool,
|
||||
) -> ExecApprovalRequirement {
|
||||
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule,
|
||||
&evaluation.matched_rules,
|
||||
);
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: derive_forbidden_reason(command, evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
if matches!(approval_policy, AskForApproval::Never) {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: PROMPT_CONFLICT_REASON.to_string(),
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
// Bypass sandbox if execpolicy allows the command
|
||||
bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
|
||||
}),
|
||||
proposed_execpolicy_amendment: if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecPolicyManager {
|
||||
fn default() -> Self {
|
||||
Self::new(Arc::new(Policy::empty()))
|
||||
@@ -1221,6 +1330,130 @@ prefix_rule(
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_overlay_merges_with_base_policy_without_mutating_manager() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let overlay_prefix = vec!["/tmp/skill-script.sh".to_string()];
|
||||
|
||||
let overlay_prompted = manager
|
||||
.create_exec_approval_requirement_for_command_with_overlay(
|
||||
ExecApprovalRequest {
|
||||
command: &overlay_prefix,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
&[overlay_prefix.clone()],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
overlay_prompted,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("`/tmp/skill-script.sh` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
|
||||
let rm_command = vec!["rm".to_string(), "-f".to_string(), "tmp.txt".to_string()];
|
||||
let base_rule_still_applies = manager
|
||||
.create_exec_approval_requirement_for_command_with_overlay(
|
||||
ExecApprovalRequest {
|
||||
command: &rm_command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
&[overlay_prefix],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
base_rule_still_applies,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("`rm -f tmp.txt` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
manager.current().get_allowed_prefixes(),
|
||||
Vec::<Vec<String>>::new(),
|
||||
"overlay prefixes must not persist in session execpolicy state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_overlay_does_not_override_base_allow_rule_for_same_prefix() {
|
||||
let policy_src = r#"prefix_rule(pattern=["/tmp/skill-script.sh"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec!["/tmp/skill-script.sh".to_string()];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command_with_overlay(
|
||||
ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
std::slice::from_ref(&command),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_overlay_does_not_override_base_prompt_reason_for_same_prefix() {
|
||||
let policy_src = r#"prefix_rule(pattern=["/tmp/skill-script.sh"], decision="prompt", justification="base policy prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec!["/tmp/skill-script.sh".to_string()];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command_with_overlay(
|
||||
ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
std::slice::from_ref(&command),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(
|
||||
"`/tmp/skill-script.sh` requires approval: base policy prompt".to_string()
|
||||
),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_bash_lc_script_falls_back_to_original_command() {
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
|
||||
|
||||
@@ -6,6 +6,8 @@ sandbox placement and transformation of portable CommandSpec into a
|
||||
ready‑to‑spawn environment.
|
||||
*/
|
||||
|
||||
pub(crate) mod policy_merge;
|
||||
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,6 +19,7 @@ use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use crate::skills::SkillMetadata;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
type MacOsSeatbeltProfileExtensions = ();
|
||||
|
||||
@@ -118,6 +121,31 @@ pub(crate) fn compile_permission_profile(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_script_prefix_permissions(
|
||||
skills: &[SkillMetadata],
|
||||
) -> HashMap<Vec<String>, Permissions> {
|
||||
let mut entries = HashMap::new();
|
||||
|
||||
for skill in skills {
|
||||
let Some(skill_permissions) = skill.permissions.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(skill_dir) = skill.path.parent() else {
|
||||
warn!(
|
||||
"ignoring skill script prefix permissions for {}: SKILL.md has no parent directory",
|
||||
skill.path.display()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
for script_path in normalized_skill_script_paths(skill_dir) {
|
||||
entries.insert(vec![script_path], skill_permissions.clone());
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn normalize_permission_paths(
|
||||
skill_dir: &Path,
|
||||
values: &[String],
|
||||
@@ -138,6 +166,55 @@ fn normalize_permission_paths(
|
||||
paths
|
||||
}
|
||||
|
||||
/// Returns unique script file paths for a skill's top-level `scripts/` directory.
|
||||
///
|
||||
/// The returned paths are normalized lexically, canonicalized when possible, and
|
||||
/// rendered as strings. Non-file entries are ignored. If the directory does not
|
||||
/// exist (or can't be read), an empty list is returned after logging a warning
|
||||
/// for non-`NotFound` errors.
|
||||
fn normalized_skill_script_paths(skill_dir: &Path) -> Vec<String> {
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
let entries = match std::fs::read_dir(&scripts_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return Vec::new(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"ignoring skill scripts directory {}: {err}",
|
||||
scripts_dir.display()
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut normalized_paths = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"ignoring entry in skill scripts directory {}: {err}",
|
||||
scripts_dir.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let is_file = entry.file_type().is_ok_and(|file_type| file_type.is_file());
|
||||
if !is_file {
|
||||
continue;
|
||||
}
|
||||
let normalized = normalize_lexically(&path);
|
||||
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
|
||||
let rendered = canonicalized.to_string_lossy().to_string();
|
||||
if seen.insert(rendered.clone()) {
|
||||
normalized_paths.push(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
normalized_paths
|
||||
}
|
||||
|
||||
fn normalize_permission_path(
|
||||
skill_dir: &Path,
|
||||
value: &str,
|
||||
|
||||
@@ -4,11 +4,15 @@ use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
#[cfg(unix)]
|
||||
use crate::error::CodexErr;
|
||||
#[cfg(unix)]
|
||||
use crate::error::SandboxErr;
|
||||
#[cfg(unix)]
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::EventMsg;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
@@ -17,12 +21,18 @@ use crate::protocol::ExecOutputStream;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ReviewDecision;
|
||||
#[cfg(unix)]
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(unix)]
|
||||
use crate::sandboxing::policy_merge::extend_sandbox_policy;
|
||||
#[cfg(unix)]
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
#[cfg(unix)]
|
||||
use codex_utils_pty::process_group::kill_child_process_group;
|
||||
#[cfg(unix)]
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
#[cfg(unix)]
|
||||
use serde::Deserialize;
|
||||
#[cfg(unix)]
|
||||
use serde::Serialize;
|
||||
@@ -31,6 +41,10 @@ use std::io::Read;
|
||||
#[cfg(unix)]
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::path::Component;
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -272,7 +286,14 @@ impl ZshExecBridge {
|
||||
ToolError::Rejected(format!("failed to accept wrapper request: {err}"))
|
||||
})?;
|
||||
if self
|
||||
.handle_wrapper_request(stream, req.justification.clone(), session, turn, call_id)
|
||||
.handle_wrapper_request(
|
||||
stream,
|
||||
req.justification.clone(),
|
||||
req.sandbox_permissions,
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
user_rejected = true;
|
||||
@@ -321,6 +342,7 @@ impl ZshExecBridge {
|
||||
&self,
|
||||
mut stream: UnixStream,
|
||||
approval_reason: Option<String>,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
@@ -348,6 +370,102 @@ impl ZshExecBridge {
|
||||
} else {
|
||||
argv.clone()
|
||||
};
|
||||
let normalized_exec_program = normalize_wrapper_executable_for_skill_matching(&file, &cwd);
|
||||
let normalized_wrapped_exec_program =
|
||||
normalize_wrapped_shell_inner_executable_for_skill_matching(
|
||||
&command_for_approval,
|
||||
&cwd,
|
||||
);
|
||||
|
||||
// Check exec policy against the resolved executable path, not the original argv[0].
|
||||
let mut command_for_execpolicy = command_for_approval.clone();
|
||||
if let Some(program) = command_for_execpolicy.first_mut() {
|
||||
*program = normalized_exec_program.clone();
|
||||
}
|
||||
|
||||
// Collect skill-provided approval overlays and any matching skill permission profile.
|
||||
let (overlay_prompt_prefixes, matched_skill_permissions) = {
|
||||
let registry = turn
|
||||
.skill_prefix_permissions
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let matched_skill_permissions = registry
|
||||
.get(std::slice::from_ref(&normalized_exec_program))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
// Wrapper requests often execute `zsh -c/-lc <skill-script>`, so the
|
||||
// wrapper executable (`zsh`) won't match skill script prefixes. Fall back
|
||||
// to the parsed inner executable path to match skill permissions.
|
||||
normalized_wrapped_exec_program
|
||||
.as_ref()
|
||||
.and_then(|program| registry.get(std::slice::from_ref(program)).cloned())
|
||||
});
|
||||
let overlay_prompt_prefixes = registry.keys().cloned().collect::<Vec<_>>();
|
||||
(overlay_prompt_prefixes, matched_skill_permissions)
|
||||
};
|
||||
let effective_sandbox_policy = matched_skill_permissions.as_ref().map_or_else(
|
||||
|| turn.sandbox_policy.clone(),
|
||||
|skill_permissions| {
|
||||
extend_sandbox_policy(&turn.sandbox_policy, skill_permissions.sandbox_policy.get())
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
?effective_sandbox_policy,
|
||||
?matched_skill_permissions,
|
||||
?sandbox_permissions,
|
||||
"zsh exec bridge resolved sandbox policy for wrapper request"
|
||||
);
|
||||
|
||||
// Ask exec policy whether this command can run, is forbidden, or needs approval.
|
||||
let exec_approval_requirement = session
|
||||
.services
|
||||
.exec_policy
|
||||
.create_exec_approval_requirement_for_command_with_overlay(
|
||||
ExecApprovalRequest {
|
||||
command: &command_for_execpolicy,
|
||||
approval_policy: turn.approval_policy,
|
||||
sandbox_policy: &effective_sandbox_policy,
|
||||
sandbox_permissions,
|
||||
prefix_rule: None,
|
||||
},
|
||||
&overlay_prompt_prefixes,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send immediate allow/deny responses, or carry forward approval details for prompting.
|
||||
let (reason, proposed_execpolicy_amendment) = match exec_approval_requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => {
|
||||
write_json_line(
|
||||
&mut stream,
|
||||
&WrapperIpcResponse::ExecResponse {
|
||||
request_id,
|
||||
action: WrapperExecAction::Run,
|
||||
reason: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(false);
|
||||
}
|
||||
ExecApprovalRequirement::Forbidden { reason } => {
|
||||
write_json_line(
|
||||
&mut stream,
|
||||
&WrapperIpcResponse::ExecResponse {
|
||||
request_id,
|
||||
action: WrapperExecAction::Deny,
|
||||
reason: Some(reason),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
} => (
|
||||
reason.or(approval_reason.clone()),
|
||||
proposed_execpolicy_amendment,
|
||||
),
|
||||
};
|
||||
|
||||
let approval_id = Uuid::new_v4().to_string();
|
||||
let decision = session
|
||||
@@ -357,9 +475,9 @@ impl ZshExecBridge {
|
||||
Some(approval_id),
|
||||
command_for_approval,
|
||||
PathBuf::from(cwd),
|
||||
approval_reason,
|
||||
reason,
|
||||
None,
|
||||
None::<ExecPolicyAmendment>,
|
||||
proposed_execpolicy_amendment,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -520,6 +638,53 @@ fn parse_wrapper_request_line(request_line: &str) -> Result<WrapperIpcRequest, T
|
||||
.map_err(|err| ToolError::Rejected(format!("parse wrapper request payload: {err}")))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn normalize_wrapper_executable_for_skill_matching(file: &str, cwd: &str) -> String {
|
||||
let file_path = Path::new(file);
|
||||
if !file_path.is_absolute() && !file.contains('/') {
|
||||
return file.to_string();
|
||||
}
|
||||
|
||||
let absolute = if file_path.is_absolute() {
|
||||
file_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(cwd).join(file_path)
|
||||
};
|
||||
let normalized = normalize_lexically(&absolute);
|
||||
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
|
||||
canonicalized.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn normalize_wrapped_shell_inner_executable_for_skill_matching(
|
||||
command: &[String],
|
||||
cwd: &str,
|
||||
) -> Option<String> {
|
||||
let commands = parse_shell_lc_plain_commands(command)?;
|
||||
let first_command = commands.first()?;
|
||||
let program = first_command.first()?;
|
||||
Some(normalize_wrapper_executable_for_skill_matching(
|
||||
program, cwd,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
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(unix)]
|
||||
async fn write_json_line<W: tokio::io::AsyncWrite + Unpin, T: Serialize>(
|
||||
writer: &mut W,
|
||||
@@ -542,6 +707,8 @@ async fn write_json_line<W: tokio::io::AsyncWrite + Unpin, T: Serialize>(
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn parse_wrapper_request_line_rejects_malformed_json() {
|
||||
@@ -551,4 +718,73 @@ mod tests {
|
||||
};
|
||||
assert!(message.starts_with("parse wrapper request payload:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wrapper_executable_for_skill_matching_resolves_relative_paths_against_cwd() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let repo_root = tempdir.path().join("repo");
|
||||
let cwd = repo_root.join("codex-rs");
|
||||
let skill_script =
|
||||
repo_root.join(".agents/skills/sandbox-approval-demo/scripts/skill_action.sh");
|
||||
fs::create_dir_all(
|
||||
skill_script
|
||||
.parent()
|
||||
.expect("skill script should have a parent directory"),
|
||||
)
|
||||
.expect("create skill scripts dir");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
fs::write(&skill_script, "#!/bin/sh\n").expect("write skill script");
|
||||
|
||||
let normalized = normalize_wrapper_executable_for_skill_matching(
|
||||
"../.agents/skills/sandbox-approval-demo/scripts/./skill_action.sh",
|
||||
&cwd.to_string_lossy(),
|
||||
);
|
||||
|
||||
let expected = dunce::canonicalize(&skill_script)
|
||||
.expect("canonicalize skill script")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wrapper_executable_for_skill_matching_keeps_bare_command_names() {
|
||||
let normalized = normalize_wrapper_executable_for_skill_matching("bash", "/tmp");
|
||||
assert_eq!(normalized, "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wrapped_shell_inner_executable_for_skill_matching_resolves_relative_script() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let repo_root = tempdir.path().join("repo");
|
||||
let cwd = repo_root.join("codex-rs");
|
||||
let skill_script =
|
||||
repo_root.join(".agents/skills/sandbox-approval-demo/scripts/skill_action.sh");
|
||||
fs::create_dir_all(
|
||||
skill_script
|
||||
.parent()
|
||||
.expect("skill script should have a parent directory"),
|
||||
)
|
||||
.expect("create skill scripts dir");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
fs::write(&skill_script, "#!/bin/sh\n").expect("write skill script");
|
||||
|
||||
let command = vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-c".to_string(),
|
||||
"../.agents/skills/sandbox-approval-demo/scripts/./skill_action.sh".to_string(),
|
||||
];
|
||||
|
||||
let normalized = normalize_wrapped_shell_inner_executable_for_skill_matching(
|
||||
&command,
|
||||
&cwd.to_string_lossy(),
|
||||
)
|
||||
.expect("should parse wrapped shell command");
|
||||
|
||||
let expected = dunce::canonicalize(&skill_script)
|
||||
.expect("canonicalize skill script")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user