2025-10-20 09:11:43 -07:00
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
|
|
|
|
|
|
use anyhow::Result;
|
2025-11-07 16:36:04 -08:00
|
|
|
use codex_core::features::Feature;
|
2025-10-20 09:11:43 -07:00
|
|
|
use codex_core::model_family::find_family_for_model;
|
|
|
|
|
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
|
|
|
|
use codex_core::protocol::AskForApproval;
|
|
|
|
|
use codex_core::protocol::EventMsg;
|
|
|
|
|
use codex_core::protocol::ExecApprovalRequestEvent;
|
|
|
|
|
use codex_core::protocol::Op;
|
|
|
|
|
use codex_core::protocol::SandboxPolicy;
|
|
|
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
|
|
|
use codex_protocol::protocol::ReviewDecision;
|
2025-10-20 13:34:44 -07:00
|
|
|
use codex_protocol::user_input::UserInput;
|
2025-10-20 09:11:43 -07:00
|
|
|
use core_test_support::responses::ev_apply_patch_function_call;
|
|
|
|
|
use core_test_support::responses::ev_assistant_message;
|
|
|
|
|
use core_test_support::responses::ev_completed;
|
|
|
|
|
use core_test_support::responses::ev_function_call;
|
|
|
|
|
use core_test_support::responses::ev_response_created;
|
|
|
|
|
use core_test_support::responses::mount_sse_once;
|
|
|
|
|
use core_test_support::responses::sse;
|
|
|
|
|
use core_test_support::responses::start_mock_server;
|
|
|
|
|
use core_test_support::skip_if_no_network;
|
|
|
|
|
use core_test_support::test_codex::TestCodex;
|
|
|
|
|
use core_test_support::test_codex::test_codex;
|
|
|
|
|
use core_test_support::wait_for_event;
|
|
|
|
|
use pretty_assertions::assert_eq;
|
2025-11-07 16:36:04 -08:00
|
|
|
use regex_lite::Regex;
|
2025-10-20 09:11:43 -07:00
|
|
|
use serde_json::Value;
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use std::env;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use wiremock::Mock;
|
|
|
|
|
use wiremock::MockServer;
|
|
|
|
|
use wiremock::ResponseTemplate;
|
|
|
|
|
use wiremock::matchers::method;
|
|
|
|
|
use wiremock::matchers::path;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
enum TargetPath {
|
|
|
|
|
Workspace(&'static str),
|
|
|
|
|
OutsideWorkspace(&'static str),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TargetPath {
|
|
|
|
|
fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) {
|
|
|
|
|
match self {
|
|
|
|
|
TargetPath::Workspace(name) => {
|
|
|
|
|
let path = test.cwd.path().join(name);
|
|
|
|
|
(path, name.to_string())
|
|
|
|
|
}
|
|
|
|
|
TargetPath::OutsideWorkspace(name) => {
|
|
|
|
|
let path = env::current_dir()
|
|
|
|
|
.expect("current dir should be available")
|
|
|
|
|
.join(name);
|
|
|
|
|
(path.clone(), path.display().to_string())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
enum ActionKind {
|
|
|
|
|
WriteFile {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
content: &'static str,
|
|
|
|
|
},
|
|
|
|
|
FetchUrl {
|
|
|
|
|
endpoint: &'static str,
|
|
|
|
|
response_body: &'static str,
|
|
|
|
|
},
|
|
|
|
|
RunCommand {
|
|
|
|
|
command: &'static [&'static str],
|
|
|
|
|
},
|
2025-11-07 16:36:04 -08:00
|
|
|
RunUnifiedExecCommand {
|
|
|
|
|
command: &'static str,
|
|
|
|
|
},
|
2025-10-20 09:11:43 -07:00
|
|
|
ApplyPatchFunction {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
content: &'static str,
|
|
|
|
|
},
|
|
|
|
|
ApplyPatchShell {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
content: &'static str,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ActionKind {
|
|
|
|
|
async fn prepare(
|
|
|
|
|
&self,
|
|
|
|
|
test: &TestCodex,
|
|
|
|
|
server: &MockServer,
|
|
|
|
|
call_id: &str,
|
|
|
|
|
with_escalated_permissions: bool,
|
|
|
|
|
) -> Result<(Value, Option<Vec<String>>)> {
|
|
|
|
|
match self {
|
|
|
|
|
ActionKind::WriteFile { target, content } => {
|
|
|
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
|
|
|
let _ = fs::remove_file(&path);
|
|
|
|
|
let command = vec![
|
|
|
|
|
"/bin/sh".to_string(),
|
|
|
|
|
"-c".to_string(),
|
|
|
|
|
format!(
|
|
|
|
|
"printf {content:?} > {path:?} && cat {path:?}",
|
|
|
|
|
content = content,
|
|
|
|
|
path = path
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
|
|
|
|
|
Ok((event, Some(command)))
|
|
|
|
|
}
|
|
|
|
|
ActionKind::FetchUrl {
|
|
|
|
|
endpoint,
|
|
|
|
|
response_body,
|
|
|
|
|
} => {
|
|
|
|
|
Mock::given(method("GET"))
|
|
|
|
|
.and(path(*endpoint))
|
|
|
|
|
.respond_with(
|
|
|
|
|
ResponseTemplate::new(200).set_body_string(response_body.to_string()),
|
|
|
|
|
)
|
|
|
|
|
.mount(server)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let url = format!("{}{}", server.uri(), endpoint);
|
|
|
|
|
let script = format!(
|
|
|
|
|
"import sys\nimport urllib.request\nurl = {url:?}\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let command = vec!["python3".to_string(), "-c".to_string(), script];
|
|
|
|
|
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
|
|
|
|
|
Ok((event, Some(command)))
|
|
|
|
|
}
|
|
|
|
|
ActionKind::RunCommand { command } => {
|
|
|
|
|
let command: Vec<String> = command
|
|
|
|
|
.iter()
|
|
|
|
|
.map(std::string::ToString::to_string)
|
|
|
|
|
.collect();
|
|
|
|
|
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
|
|
|
|
|
Ok((event, Some(command)))
|
|
|
|
|
}
|
2025-11-07 16:36:04 -08:00
|
|
|
ActionKind::RunUnifiedExecCommand { command } => {
|
|
|
|
|
let event = exec_command_event(call_id, command, Some(1000))?;
|
|
|
|
|
Ok((
|
|
|
|
|
event,
|
|
|
|
|
Some(vec![
|
|
|
|
|
"/bin/bash".to_string(),
|
|
|
|
|
"-lc".to_string(),
|
|
|
|
|
command.to_string(),
|
|
|
|
|
]),
|
|
|
|
|
))
|
|
|
|
|
}
|
2025-10-20 09:11:43 -07:00
|
|
|
ActionKind::ApplyPatchFunction { target, content } => {
|
|
|
|
|
let (path, patch_path) = target.resolve_for_patch(test);
|
|
|
|
|
let _ = fs::remove_file(&path);
|
|
|
|
|
let patch = build_add_file_patch(&patch_path, content);
|
|
|
|
|
Ok((ev_apply_patch_function_call(call_id, &patch), None))
|
|
|
|
|
}
|
|
|
|
|
ActionKind::ApplyPatchShell { target, content } => {
|
|
|
|
|
let (path, patch_path) = target.resolve_for_patch(test);
|
|
|
|
|
let _ = fs::remove_file(&path);
|
|
|
|
|
let patch = build_add_file_patch(&patch_path, content);
|
|
|
|
|
let command = shell_apply_patch_command(&patch);
|
|
|
|
|
let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?;
|
|
|
|
|
Ok((event, Some(command)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_add_file_patch(patch_path: &str, content: &str) -> String {
|
|
|
|
|
format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shell_apply_patch_command(patch: &str) -> Vec<String> {
|
|
|
|
|
let mut script = String::from("apply_patch <<'PATCH'\n");
|
|
|
|
|
script.push_str(patch);
|
|
|
|
|
if !patch.ends_with('\n') {
|
|
|
|
|
script.push('\n');
|
|
|
|
|
}
|
|
|
|
|
script.push_str("PATCH\n");
|
|
|
|
|
vec!["bash".to_string(), "-lc".to_string(), script]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shell_event(
|
|
|
|
|
call_id: &str,
|
|
|
|
|
command: &[String],
|
|
|
|
|
timeout_ms: u64,
|
|
|
|
|
with_escalated_permissions: bool,
|
|
|
|
|
) -> Result<Value> {
|
|
|
|
|
let mut args = json!({
|
|
|
|
|
"command": command,
|
|
|
|
|
"timeout_ms": timeout_ms,
|
|
|
|
|
});
|
|
|
|
|
if with_escalated_permissions {
|
|
|
|
|
args["with_escalated_permissions"] = json!(true);
|
|
|
|
|
}
|
|
|
|
|
let args_str = serde_json::to_string(&args)?;
|
|
|
|
|
Ok(ev_function_call(call_id, "shell", &args_str))
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-07 16:36:04 -08:00
|
|
|
fn exec_command_event(call_id: &str, cmd: &str, yield_time_ms: Option<u64>) -> Result<Value> {
|
|
|
|
|
let mut args = json!({
|
|
|
|
|
"cmd": cmd.to_string(),
|
|
|
|
|
});
|
|
|
|
|
if let Some(yield_time_ms) = yield_time_ms {
|
|
|
|
|
args["yield_time_ms"] = json!(yield_time_ms);
|
|
|
|
|
}
|
|
|
|
|
let args_str = serde_json::to_string(&args)?;
|
|
|
|
|
Ok(ev_function_call(call_id, "exec_command", &args_str))
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-20 09:11:43 -07:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
enum Expectation {
|
|
|
|
|
FileCreated {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
content: &'static str,
|
|
|
|
|
},
|
|
|
|
|
PatchApplied {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
content: &'static str,
|
|
|
|
|
},
|
|
|
|
|
FileNotCreated {
|
|
|
|
|
target: TargetPath,
|
|
|
|
|
message_contains: &'static [&'static str],
|
|
|
|
|
},
|
|
|
|
|
NetworkSuccess {
|
|
|
|
|
body_contains: &'static str,
|
|
|
|
|
},
|
|
|
|
|
NetworkFailure {
|
|
|
|
|
expect_tag: &'static str,
|
|
|
|
|
},
|
|
|
|
|
CommandSuccess {
|
|
|
|
|
stdout_contains: &'static str,
|
|
|
|
|
},
|
2025-11-07 16:36:04 -08:00
|
|
|
CommandFailure {
|
|
|
|
|
output_contains: &'static str,
|
|
|
|
|
},
|
2025-10-20 09:11:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Expectation {
|
|
|
|
|
fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> {
|
|
|
|
|
match self {
|
|
|
|
|
Expectation::FileCreated { target, content } => {
|
|
|
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected successful exit for {:?}",
|
|
|
|
|
path
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(content),
|
|
|
|
|
"stdout missing {content:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
let file_contents = fs::read_to_string(&path)?;
|
|
|
|
|
assert!(
|
|
|
|
|
file_contents.contains(content),
|
|
|
|
|
"file contents missing {content:?}: {file_contents}"
|
|
|
|
|
);
|
|
|
|
|
let _ = fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
Expectation::PatchApplied { target, content } => {
|
|
|
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
|
|
|
match result.exit_code {
|
|
|
|
|
Some(0) | None => {
|
|
|
|
|
if result.exit_code.is_none() {
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains("Success."),
|
|
|
|
|
"patch output missing success indicator: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(code) => panic!(
|
|
|
|
|
"expected successful patch exit for {:?}, got {code} with stdout {}",
|
|
|
|
|
path, result.stdout
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
let file_contents = fs::read_to_string(&path)?;
|
|
|
|
|
assert!(
|
|
|
|
|
file_contents.contains(content),
|
|
|
|
|
"patched file missing {content:?}: {file_contents}"
|
|
|
|
|
);
|
|
|
|
|
let _ = fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
Expectation::FileNotCreated {
|
|
|
|
|
target,
|
|
|
|
|
message_contains,
|
|
|
|
|
} => {
|
|
|
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
|
|
|
assert_ne!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected non-zero exit for {path:?}"
|
|
|
|
|
);
|
|
|
|
|
for needle in *message_contains {
|
2025-10-29 18:21:18 +00:00
|
|
|
if needle.contains('|') {
|
|
|
|
|
let options: Vec<&str> = needle.split('|').collect();
|
|
|
|
|
let matches_any =
|
|
|
|
|
options.iter().any(|option| result.stdout.contains(option));
|
|
|
|
|
assert!(
|
|
|
|
|
matches_any,
|
|
|
|
|
"stdout missing one of {options:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(needle),
|
|
|
|
|
"stdout missing {needle:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-10-20 09:11:43 -07:00
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
!path.exists(),
|
|
|
|
|
"command should not create {path:?}, but file exists"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Expectation::NetworkSuccess { body_contains } => {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected successful network exit: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains("OK:"),
|
|
|
|
|
"stdout missing OK prefix: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(body_contains),
|
|
|
|
|
"stdout missing body text {body_contains:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Expectation::NetworkFailure { expect_tag } => {
|
|
|
|
|
assert_ne!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected non-zero exit for network failure: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains("ERR:"),
|
|
|
|
|
"stdout missing ERR prefix: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(expect_tag),
|
|
|
|
|
"stdout missing expected tag {expect_tag:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Expectation::CommandSuccess { stdout_contains } => {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected successful trusted command exit: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(stdout_contains),
|
|
|
|
|
"trusted command stdout missing {stdout_contains:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-11-07 16:36:04 -08:00
|
|
|
Expectation::CommandFailure { output_contains } => {
|
|
|
|
|
assert_ne!(
|
|
|
|
|
result.exit_code,
|
|
|
|
|
Some(0),
|
|
|
|
|
"expected non-zero exit for command failure: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.stdout.contains(output_contains),
|
|
|
|
|
"command failure stderr missing {output_contains:?}: {}",
|
|
|
|
|
result.stdout
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-10-20 09:11:43 -07:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
enum Outcome {
|
|
|
|
|
Auto,
|
|
|
|
|
ExecApproval {
|
|
|
|
|
decision: ReviewDecision,
|
|
|
|
|
expected_reason: Option<&'static str>,
|
|
|
|
|
},
|
|
|
|
|
PatchApproval {
|
|
|
|
|
decision: ReviewDecision,
|
|
|
|
|
expected_reason: Option<&'static str>,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
struct ScenarioSpec {
|
|
|
|
|
name: &'static str,
|
|
|
|
|
approval_policy: AskForApproval,
|
|
|
|
|
sandbox_policy: SandboxPolicy,
|
|
|
|
|
action: ActionKind,
|
|
|
|
|
with_escalated_permissions: bool,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: Vec<Feature>,
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Option<&'static str>,
|
|
|
|
|
outcome: Outcome,
|
|
|
|
|
expectation: Expectation,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct CommandResult {
|
|
|
|
|
exit_code: Option<i64>,
|
|
|
|
|
stdout: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn submit_turn(
|
|
|
|
|
test: &TestCodex,
|
|
|
|
|
prompt: &str,
|
|
|
|
|
approval_policy: AskForApproval,
|
|
|
|
|
sandbox_policy: SandboxPolicy,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let session_model = test.session_configured.model.clone();
|
|
|
|
|
|
|
|
|
|
test.codex
|
|
|
|
|
.submit(Op::UserTurn {
|
2025-10-20 13:34:44 -07:00
|
|
|
items: vec![UserInput::Text {
|
2025-10-20 09:11:43 -07:00
|
|
|
text: prompt.into(),
|
|
|
|
|
}],
|
|
|
|
|
final_output_json_schema: None,
|
|
|
|
|
cwd: test.cwd.path().to_path_buf(),
|
|
|
|
|
approval_policy,
|
|
|
|
|
sandbox_policy,
|
|
|
|
|
model: session_model,
|
|
|
|
|
effort: None,
|
|
|
|
|
summary: ReasoningSummary::Auto,
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_result(item: &Value) -> CommandResult {
|
|
|
|
|
let output_str = item
|
|
|
|
|
.get("output")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.expect("shell output payload");
|
|
|
|
|
match serde_json::from_str::<Value>(output_str) {
|
|
|
|
|
Ok(parsed) => {
|
|
|
|
|
let exit_code = parsed["metadata"]["exit_code"].as_i64();
|
|
|
|
|
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
|
|
|
|
|
CommandResult { exit_code, stdout }
|
|
|
|
|
}
|
2025-11-07 16:36:04 -08:00
|
|
|
Err(_) => {
|
|
|
|
|
let regex =
|
|
|
|
|
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
|
|
|
|
|
// parse freeform output
|
|
|
|
|
if let Some(captures) = regex.captures(output_str) {
|
|
|
|
|
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
|
|
|
|
|
let output = captures.get(2).unwrap().as_str();
|
|
|
|
|
CommandResult {
|
|
|
|
|
exit_code: Some(exit_code),
|
|
|
|
|
stdout: output.to_string(),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
CommandResult {
|
|
|
|
|
exit_code: None,
|
|
|
|
|
stdout: output_str.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-20 09:11:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn expect_exec_approval(
|
|
|
|
|
test: &TestCodex,
|
|
|
|
|
expected_command: &[String],
|
|
|
|
|
) -> ExecApprovalRequestEvent {
|
2025-11-06 18:11:11 -08:00
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
|
|
|
matches!(
|
|
|
|
|
event,
|
|
|
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
|
|
|
|
|
)
|
|
|
|
|
})
|
2025-10-20 09:11:43 -07:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match event {
|
|
|
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
|
|
|
assert_eq!(approval.command, expected_command);
|
|
|
|
|
approval
|
|
|
|
|
}
|
|
|
|
|
EventMsg::TaskComplete(_) => panic!("expected approval request before completion"),
|
|
|
|
|
other => panic!("unexpected event: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn expect_patch_approval(
|
|
|
|
|
test: &TestCodex,
|
|
|
|
|
expected_call_id: &str,
|
|
|
|
|
) -> ApplyPatchApprovalRequestEvent {
|
2025-11-06 18:11:11 -08:00
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
|
|
|
matches!(
|
|
|
|
|
event,
|
|
|
|
|
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_)
|
|
|
|
|
)
|
|
|
|
|
})
|
2025-10-20 09:11:43 -07:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match event {
|
|
|
|
|
EventMsg::ApplyPatchApprovalRequest(approval) => {
|
|
|
|
|
assert_eq!(approval.call_id, expected_call_id);
|
|
|
|
|
approval
|
|
|
|
|
}
|
|
|
|
|
EventMsg::TaskComplete(_) => panic!("expected patch approval request before completion"),
|
|
|
|
|
other => panic!("unexpected event: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn wait_for_completion_without_approval(test: &TestCodex) {
|
2025-11-06 18:11:11 -08:00
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
|
|
|
matches!(
|
|
|
|
|
event,
|
|
|
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
|
|
|
|
|
)
|
|
|
|
|
})
|
2025-10-20 09:11:43 -07:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match event {
|
|
|
|
|
EventMsg::TaskComplete(_) => {}
|
|
|
|
|
EventMsg::ExecApprovalRequest(event) => {
|
|
|
|
|
panic!("unexpected approval request: {:?}", event.command)
|
|
|
|
|
}
|
|
|
|
|
other => panic!("unexpected event: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn wait_for_completion(test: &TestCodex) {
|
|
|
|
|
wait_for_event(&test.codex, |event| {
|
|
|
|
|
matches!(event, EventMsg::TaskComplete(_))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scenarios() -> Vec<ScenarioSpec> {
|
|
|
|
|
use AskForApproval::*;
|
|
|
|
|
|
|
|
|
|
let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite {
|
|
|
|
|
writable_roots: vec![],
|
|
|
|
|
network_access,
|
|
|
|
|
exclude_tmpdir_env_var: false,
|
|
|
|
|
exclude_slash_tmp: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vec![
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "danger_full_access_on_request_allows_outside_write",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_on_request.txt"),
|
|
|
|
|
content: "danger-on-request",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_on_request.txt"),
|
|
|
|
|
content: "danger-on-request",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "danger_full_access_on_request_allows_network",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::FetchUrl {
|
|
|
|
|
endpoint: "/dfa/network",
|
|
|
|
|
response_body: "danger-network-ok",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::NetworkSuccess {
|
|
|
|
|
body_contains: "danger-network-ok",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "trusted_command_unless_trusted_runs_without_prompt",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::RunCommand {
|
|
|
|
|
command: &["echo", "trusted-unless"],
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::CommandSuccess {
|
|
|
|
|
stdout_contains: "trusted-unless",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "danger_full_access_on_failure_allows_outside_write",
|
|
|
|
|
approval_policy: OnFailure,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"),
|
|
|
|
|
content: "danger-on-failure",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"),
|
|
|
|
|
content: "danger-on-failure",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "danger_full_access_unless_trusted_requests_approval",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"),
|
|
|
|
|
content: "danger-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"),
|
|
|
|
|
content: "danger-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "danger_full_access_never_allows_outside_write",
|
|
|
|
|
approval_policy: Never,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_never.txt"),
|
|
|
|
|
content: "danger-never",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("dfa_never.txt"),
|
|
|
|
|
content: "danger-never",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_on_request_requires_approval",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_request.txt"),
|
|
|
|
|
content: "read-only-approval",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: true,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_request.txt"),
|
|
|
|
|
content: "read-only-approval",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "trusted_command_on_request_read_only_runs_without_prompt",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::RunCommand {
|
|
|
|
|
command: &["echo", "trusted-read-only"],
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::CommandSuccess {
|
|
|
|
|
stdout_contains: "trusted-read-only",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_on_request_blocks_network",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::FetchUrl {
|
|
|
|
|
endpoint: "/ro/network-blocked",
|
|
|
|
|
response_body: "should-not-see",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_on_request_denied_blocks_execution",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_request_denied.txt"),
|
|
|
|
|
content: "should-not-write",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: true,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Denied,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileNotCreated {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_request_denied.txt"),
|
|
|
|
|
message_contains: &["exec command rejected by user"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
#[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_on_failure_escalates_after_sandbox_error",
|
|
|
|
|
approval_policy: OnFailure,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_failure.txt"),
|
|
|
|
|
content: "read-only-on-failure",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: Some("command failed; retry without sandbox?"),
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::Workspace("ro_on_failure.txt"),
|
|
|
|
|
content: "read-only-on-failure",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_on_request_network_escalates_when_approved",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::FetchUrl {
|
|
|
|
|
endpoint: "/ro/network-approved",
|
|
|
|
|
response_body: "read-only-network-ok",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: true,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::NetworkSuccess {
|
|
|
|
|
body_contains: "read-only-network-ok",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_shell_requires_patch_approval",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchShell {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_shell.txt"),
|
|
|
|
|
content: "shell-apply-patch",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::PatchApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_shell.txt"),
|
|
|
|
|
content: "shell-apply-patch",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_auto_inside_workspace",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_function.txt"),
|
|
|
|
|
content: "function-apply-patch",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_function.txt"),
|
|
|
|
|
content: "function-apply-patch",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_danger_allows_outside_workspace",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"),
|
|
|
|
|
content: "function-patch-danger",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![Feature::ApplyPatchFreeform],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"),
|
|
|
|
|
content: "function-patch-danger",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_outside_requires_patch_approval",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"),
|
|
|
|
|
content: "function-patch-outside",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::PatchApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"),
|
|
|
|
|
content: "function-patch-outside",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_outside_denied_blocks_patch",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"),
|
|
|
|
|
content: "function-patch-outside-denied",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::PatchApproval {
|
|
|
|
|
decision: ReviewDecision::Denied,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileNotCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"),
|
|
|
|
|
message_contains: &["patch rejected by user"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_shell_outside_requires_patch_approval",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchShell {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"),
|
|
|
|
|
content: "shell-patch-outside",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::PatchApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"),
|
|
|
|
|
content: "shell-patch-outside",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_unless_trusted_requires_patch_approval",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"),
|
|
|
|
|
content: "function-patch-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::PatchApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::PatchApplied {
|
|
|
|
|
target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"),
|
|
|
|
|
content: "function-patch-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "apply_patch_function_never_rejects_outside_workspace",
|
|
|
|
|
approval_policy: Never,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::ApplyPatchFunction {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"),
|
|
|
|
|
content: "function-patch-never",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: Some("gpt-5-codex"),
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileNotCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"),
|
|
|
|
|
message_contains: &[
|
|
|
|
|
"patch rejected: writing outside of the project; rejected by user approval settings",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_unless_trusted_requires_approval",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ro_unless_trusted.txt"),
|
|
|
|
|
content: "read-only-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::Workspace("ro_unless_trusted.txt"),
|
|
|
|
|
content: "read-only-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "read_only_never_reports_sandbox_failure",
|
|
|
|
|
approval_policy: Never,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ro_never.txt"),
|
|
|
|
|
content: "read-only-never",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileNotCreated {
|
|
|
|
|
target: TargetPath::Workspace("ro_never.txt"),
|
|
|
|
|
message_contains: if cfg!(target_os = "linux") {
|
|
|
|
|
&["Permission denied"]
|
|
|
|
|
} else {
|
2025-10-29 18:21:18 +00:00
|
|
|
&["Permission denied|Operation not permitted|Read-only file system"]
|
2025-10-20 09:11:43 -07:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "trusted_command_never_runs_without_prompt",
|
|
|
|
|
approval_policy: Never,
|
|
|
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
|
|
|
action: ActionKind::RunCommand {
|
|
|
|
|
command: &["echo", "trusted-never"],
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::CommandSuccess {
|
|
|
|
|
stdout_contains: "trusted-never",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_on_request_allows_workspace_write",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::Workspace("ww_on_request.txt"),
|
|
|
|
|
content: "workspace-on-request",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::Workspace("ww_on_request.txt"),
|
|
|
|
|
content: "workspace-on-request",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_network_disabled_blocks_network",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::FetchUrl {
|
|
|
|
|
endpoint: "/ww/network-blocked",
|
|
|
|
|
response_body: "workspace-network-blocked",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_on_request_requires_approval_outside_workspace",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"),
|
|
|
|
|
content: "workspace-on-request-outside",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: true,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"),
|
|
|
|
|
content: "workspace-on-request-outside",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_network_enabled_allows_network",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: workspace_write(true),
|
|
|
|
|
action: ActionKind::FetchUrl {
|
|
|
|
|
endpoint: "/ww/network-ok",
|
|
|
|
|
response_body: "workspace-network-ok",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::NetworkSuccess {
|
|
|
|
|
body_contains: "workspace-network-ok",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
#[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_on_failure_escalates_outside_workspace",
|
|
|
|
|
approval_policy: OnFailure,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_on_failure.txt"),
|
|
|
|
|
content: "workspace-on-failure",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: Some("command failed; retry without sandbox?"),
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_on_failure.txt"),
|
|
|
|
|
content: "workspace-on-failure",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_unless_trusted_requires_approval_outside_workspace",
|
|
|
|
|
approval_policy: UnlessTrusted,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"),
|
|
|
|
|
content: "workspace-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::FileCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"),
|
|
|
|
|
content: "workspace-unless-trusted",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "workspace_write_never_blocks_outside_workspace",
|
|
|
|
|
approval_policy: Never,
|
|
|
|
|
sandbox_policy: workspace_write(false),
|
|
|
|
|
action: ActionKind::WriteFile {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_never.txt"),
|
|
|
|
|
content: "workspace-never",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
2025-11-07 16:36:04 -08:00
|
|
|
features: vec![],
|
2025-10-20 09:11:43 -07:00
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::FileNotCreated {
|
|
|
|
|
target: TargetPath::OutsideWorkspace("ww_never.txt"),
|
|
|
|
|
message_contains: if cfg!(target_os = "linux") {
|
|
|
|
|
&["Permission denied"]
|
|
|
|
|
} else {
|
2025-10-29 18:21:18 +00:00
|
|
|
&["Permission denied|Operation not permitted|Read-only file system"]
|
2025-10-20 09:11:43 -07:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-11-07 16:36:04 -08:00
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "unified exec on request no approval for safe command",
|
|
|
|
|
approval_policy: OnRequest,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
|
|
|
command: "echo \"hello unified exec\"",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
|
|
|
|
features: vec![Feature::UnifiedExec],
|
|
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::Auto,
|
|
|
|
|
expectation: Expectation::CommandSuccess {
|
|
|
|
|
stdout_contains: "hello unified exec",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ScenarioSpec {
|
|
|
|
|
name: "unified exec on request requires approval unless trusted",
|
|
|
|
|
approval_policy: AskForApproval::UnlessTrusted,
|
|
|
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
|
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
|
|
|
command: "git reset --hard",
|
|
|
|
|
},
|
|
|
|
|
with_escalated_permissions: false,
|
|
|
|
|
features: vec![Feature::UnifiedExec],
|
|
|
|
|
model_override: None,
|
|
|
|
|
outcome: Outcome::ExecApproval {
|
|
|
|
|
decision: ReviewDecision::Denied,
|
|
|
|
|
expected_reason: None,
|
|
|
|
|
},
|
|
|
|
|
expectation: Expectation::CommandFailure {
|
|
|
|
|
output_contains: "rejected by user",
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-10-20 09:11:43 -07:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
|
|
|
async fn approval_matrix_covers_all_modes() -> Result<()> {
|
|
|
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
|
|
|
|
|
|
for scenario in scenarios() {
|
|
|
|
|
run_scenario(&scenario).await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
|
|
|
|
eprintln!("running approval scenario: {}", scenario.name);
|
|
|
|
|
let server = start_mock_server().await;
|
|
|
|
|
let approval_policy = scenario.approval_policy;
|
|
|
|
|
let sandbox_policy = scenario.sandbox_policy.clone();
|
2025-11-07 16:36:04 -08:00
|
|
|
let features = scenario.features.clone();
|
2025-10-20 09:11:43 -07:00
|
|
|
let model_override = scenario.model_override;
|
|
|
|
|
|
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
|
|
|
config.approval_policy = approval_policy;
|
|
|
|
|
config.sandbox_policy = sandbox_policy.clone();
|
|
|
|
|
let model = model_override.unwrap_or("gpt-5");
|
|
|
|
|
config.model = model.to_string();
|
|
|
|
|
config.model_family =
|
|
|
|
|
find_family_for_model(model).expect("model should map to a known family");
|
2025-11-07 16:36:04 -08:00
|
|
|
for feature in features {
|
|
|
|
|
config.features.enable(feature);
|
2025-10-20 09:11:43 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let test = builder.build(&server).await?;
|
|
|
|
|
|
|
|
|
|
let call_id = scenario.name;
|
|
|
|
|
let (event, expected_command) = scenario
|
|
|
|
|
.action
|
|
|
|
|
.prepare(&test, &server, call_id, scenario.with_escalated_permissions)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let _ = mount_sse_once(
|
|
|
|
|
&server,
|
|
|
|
|
sse(vec![
|
|
|
|
|
ev_response_created("resp-1"),
|
|
|
|
|
event,
|
|
|
|
|
ev_completed("resp-1"),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let results_mock = mount_sse_once(
|
|
|
|
|
&server,
|
|
|
|
|
sse(vec![
|
|
|
|
|
ev_assistant_message("msg-1", "done"),
|
|
|
|
|
ev_completed("resp-2"),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
submit_turn(
|
|
|
|
|
&test,
|
|
|
|
|
scenario.name,
|
|
|
|
|
scenario.approval_policy,
|
|
|
|
|
scenario.sandbox_policy.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
match &scenario.outcome {
|
|
|
|
|
Outcome::Auto => {
|
|
|
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
|
}
|
|
|
|
|
Outcome::ExecApproval {
|
|
|
|
|
decision,
|
|
|
|
|
expected_reason,
|
|
|
|
|
} => {
|
|
|
|
|
let command = expected_command
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("exec approval requires shell command");
|
|
|
|
|
let approval = expect_exec_approval(&test, command).await;
|
|
|
|
|
if let Some(expected_reason) = expected_reason {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
approval.reason.as_deref(),
|
|
|
|
|
Some(*expected_reason),
|
|
|
|
|
"unexpected approval reason for {}",
|
|
|
|
|
scenario.name
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
test.codex
|
|
|
|
|
.submit(Op::ExecApproval {
|
|
|
|
|
id: "0".into(),
|
|
|
|
|
decision: *decision,
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
wait_for_completion(&test).await;
|
|
|
|
|
}
|
|
|
|
|
Outcome::PatchApproval {
|
|
|
|
|
decision,
|
|
|
|
|
expected_reason,
|
|
|
|
|
} => {
|
|
|
|
|
let approval = expect_patch_approval(&test, call_id).await;
|
|
|
|
|
if let Some(expected_reason) = expected_reason {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
approval.reason.as_deref(),
|
|
|
|
|
Some(*expected_reason),
|
|
|
|
|
"unexpected patch approval reason for {}",
|
|
|
|
|
scenario.name
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
test.codex
|
|
|
|
|
.submit(Op::PatchApproval {
|
|
|
|
|
id: "0".into(),
|
|
|
|
|
decision: *decision,
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
wait_for_completion(&test).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let output_item = results_mock.single_request().function_call_output(call_id);
|
|
|
|
|
let result = parse_result(&output_item);
|
|
|
|
|
scenario.expectation.verify(&test, &result)?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|