diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 0f54713b97..79b1464f83 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -183,191 +183,6 @@ fn shell_event( Ok(ev_function_call(call_id, "shell", &args_str)) } -#[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, - }, -} - -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 { - 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 - ); - } - } - 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 - ); - } - } - 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, - requires_apply_patch_tool: bool, - model_override: Option<&'static str>, - outcome: Outcome, - expectation: Expectation, -} - struct CommandResult { exit_code: Option, stdout: String, @@ -405,15 +220,21 @@ fn parse_result(item: &Value) -> CommandResult { .and_then(Value::as_str) .expect("shell output payload"); match serde_json::from_str::(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 } - } - Err(_) => CommandResult { - exit_code: None, - stdout: output_str.to_string(), - }, + Ok(parsed) => parse_result_json(&parsed), + Err(_) => parse_result_freeform(output_str), + } +} + +fn parse_result_json(parsed: &Value) -> CommandResult { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } +} + +fn parse_result_freeform(output_str: &str) -> CommandResult { + CommandResult { + exit_code: None, + stdout: output_str.to_string(), } } @@ -486,591 +307,26 @@ async fn wait_for_completion(test: &TestCodex) { .await; } -fn scenarios() -> Vec { - use AskForApproval::*; - - let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { +fn workspace_write(network_access: bool) -> SandboxPolicy { + 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: true, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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 { - &["Permission denied|Operation not permitted|Read-only file system"] - }, - }, - }, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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, - requires_apply_patch_tool: false, - 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 { - &["Permission denied|Operation not permitted|Read-only file system"] - }, - }, - }, - ] -} - -#[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(); - let requires_apply_patch_tool = scenario.requires_apply_patch_tool; - let model_override = scenario.model_override; - +async fn prepare_test( + server: &MockServer, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + requires_apply_patch_tool: bool, + model_override: Option<&'static str>, +) -> Result { + let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.approval_policy = approval_policy; - config.sandbox_policy = sandbox_policy.clone(); + config.sandbox_policy = sandbox_policy_for_config.clone(); let model = model_override.unwrap_or("gpt-5"); config.model = model.to_string(); config.model_family = @@ -1079,13 +335,233 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { config.include_apply_patch_tool = true; } }); - 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) + builder.build(server).await +} + +async fn complete_auto(test: &TestCodex) -> Result<()> { + wait_for_completion_without_approval(test).await; + Ok(()) +} + +async fn complete_with_exec_approval( + test: &TestCodex, + expected_command: &[String], + decision: ReviewDecision, + expected_reason: Option<&'static str>, +) -> Result<()> { + let approval = expect_exec_approval(test, expected_command).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(expected_reason), + "unexpected approval reason", + ); + } + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision, + }) .await?; + wait_for_completion(test).await; + Ok(()) +} + +async fn complete_with_patch_approval( + test: &TestCodex, + call_id: &str, + decision: ReviewDecision, + expected_reason: Option<&'static str>, +) -> Result<()> { + 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", + ); + } + test.codex + .submit(Op::PatchApproval { + id: "0".into(), + decision, + }) + .await?; + wait_for_completion(test).await; + Ok(()) +} + +fn assert_file_created( + test: &TestCodex, + result: &CommandResult, + target: TargetPath, + content: &'static str, +) -> Result<()> { + 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); + Ok(()) +} + +fn assert_patch_applied( + test: &TestCodex, + result: &CommandResult, + target: TargetPath, + content: &'static str, +) -> Result<()> { + 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); + Ok(()) +} + +fn assert_file_not_created( + test: &TestCodex, + result: &CommandResult, + target: TargetPath, + message_contains: &'static [&'static str], +) -> Result<()> { + 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 { + 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 + ); + } + } + assert!(!path.exists(), "command should not create {path:?}"); + Ok(()) +} + +fn assert_network_success(result: &CommandResult, body_contains: &'static str) -> Result<()> { + 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 + ); + Ok(()) +} + +fn assert_network_failure(result: &CommandResult, expect_tag: &'static str) -> Result<()> { + 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 + ); + Ok(()) +} + +fn assert_command_success(result: &CommandResult, stdout_contains: &'static str) -> Result<()> { + 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 + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn danger_full_access_on_request_allows_outside_write() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "danger_full_access_on_request_allows_outside_write"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).await?; let _ = mount_sse_once( &server, @@ -1105,68 +581,1719 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { ) .await; - submit_turn( - &test, - scenario.name, - scenario.approval_policy, - scenario.sandbox_policy.clone(), - ) - .await?; + submit_turn(&test, call_id, approval_policy, 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; - } - } + complete_auto(&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)?; + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("dfa_on_request.txt"), + "danger-on-request", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn danger_full_access_on_request_allows_network() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "danger_full_access_on_request_allows_network"; + let action = ActionKind::FetchUrl { + endpoint: "/dfa/network", + response_body: "danger-network-ok", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_network_success(&result, "danger-network-ok")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trusted_command_unless_trusted_runs_without_prompt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "trusted_command_unless_trusted_runs_without_prompt"; + let action = ActionKind::RunCommand { + command: &["echo", "trusted-unless"], + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_command_success(&result, "trusted-unless")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn danger_full_access_on_failure_allows_outside_write() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::OnFailure; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "danger_full_access_on_failure_allows_outside_write"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + "danger-on-failure", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn danger_full_access_unless_trusted_requests_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "danger_full_access_unless_trusted_requests_approval"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + "danger-unless-trusted", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn danger_full_access_never_allows_outside_write() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::Never; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "danger_full_access_never_allows_outside_write"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("dfa_never.txt"), + "danger-never", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_on_request_requires_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_on_request_requires_approval"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, true).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::Workspace("ro_on_request.txt"), + "read-only-approval", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trusted_command_on_request_read_only_runs_without_prompt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "trusted_command_on_request_read_only_runs_without_prompt"; + let action = ActionKind::RunCommand { + command: &["echo", "trusted-read-only"], + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_command_success(&result, "trusted-read-only")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_on_request_blocks_network() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_on_request_blocks_network"; + let action = ActionKind::FetchUrl { + endpoint: "/ro/network-blocked", + response_body: "should-not-see", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_network_failure(&result, "ERR:")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_on_request_denied_blocks_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_on_request_denied_blocks_execution"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + content: "should-not-write", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, true).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Denied, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_not_created( + &test, + &result, + TargetPath::Workspace("ro_on_request_denied.txt"), + &["exec command rejected by user"], + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_on_failure_escalates_after_sandbox_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnFailure; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_on_failure_escalates_after_sandbox_error"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval( + &test, + exec_command, + ReviewDecision::Approved, + Some("command failed; retry without sandbox?"), + ) + .await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::Workspace("ro_on_failure.txt"), + "read-only-on-failure", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_on_request_network_escalates_when_approved() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_on_request_network_escalates_when_approved"; + let action = ActionKind::FetchUrl { + endpoint: "/ro/network-approved", + response_body: "read-only-network-ok", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, true).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_network_success(&result, "read-only-network-ok")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_shell_requires_patch_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test(&server, approval_policy, sandbox_policy.clone(), true, None).await?; + + let call_id = "apply_patch_shell_requires_patch_approval"; + let action = ActionKind::ApplyPatchShell { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_with_patch_approval(&test, call_id, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::Workspace("apply_patch_shell.txt"), + "shell-apply-patch", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_auto_inside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_auto_inside_workspace"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::Workspace("apply_patch_function.txt"), + "function-apply-patch", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_danger_allows_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_danger_allows_outside_workspace"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + "function-patch-danger", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_outside_requires_patch_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_outside_requires_patch_approval"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_with_patch_approval(&test, call_id, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + "function-patch-outside", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_outside_denied_blocks_patch() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_outside_denied_blocks_patch"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + content: "function-patch-outside-denied", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_with_patch_approval(&test, call_id, ReviewDecision::Denied, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_not_created( + &test, + &result, + TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + &["patch rejected by user"], + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_shell_outside_requires_patch_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test(&server, approval_policy, sandbox_policy.clone(), true, None).await?; + + let call_id = "apply_patch_shell_outside_requires_patch_approval"; + let action = ActionKind::ApplyPatchShell { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_with_patch_approval(&test, call_id, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + "shell-patch-outside", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_unless_trusted_requires_patch_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_unless_trusted_requires_patch_approval"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_with_patch_approval(&test, call_id, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_patch_applied( + &test, + &result, + TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + "function-patch-unless-trusted", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_never_rejects_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::Never; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + true, + Some("gpt-5-codex"), + ) + .await?; + + let call_id = "apply_patch_function_never_rejects_outside_workspace"; + let action = ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + content: "function-patch-never", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_not_created( + &test, + &result, + TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + &["patch rejected: writing outside of the project; rejected by user approval settings"], + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_unless_trusted_requires_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_unless_trusted_requires_approval"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::Workspace("ro_unless_trusted.txt"), + "read-only-unless-trusted", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_never_reports_sandbox_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::Never; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "read_only_never_reports_sandbox_failure"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ro_never.txt"), + content: "read-only-never", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_not_created( + &test, + &result, + TargetPath::Workspace("ro_never.txt"), + if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["Permission denied|Operation not permitted|Read-only file system"] + }, + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trusted_command_never_runs_without_prompt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ReadOnly; + let approval_policy = AskForApproval::Never; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "trusted_command_never_runs_without_prompt"; + let action = ActionKind::RunCommand { + command: &["echo", "trusted-never"], + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_command_success(&result, "trusted-never")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_on_request_allows_workspace_write() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_on_request_allows_workspace_write"; + let action = ActionKind::WriteFile { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::Workspace("ww_on_request.txt"), + "workspace-on-request", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_network_disabled_blocks_network() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_network_disabled_blocks_network"; + let action = ActionKind::FetchUrl { + endpoint: "/ww/network-blocked", + response_body: "workspace-network-blocked", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_network_failure(&result, "ERR:")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_on_request_requires_approval_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_on_request_requires_approval_outside_workspace"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, true).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + "workspace-on-request-outside", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_network_enabled_allows_network() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(true); + let approval_policy = AskForApproval::OnRequest; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_network_enabled_allows_network"; + let action = ActionKind::FetchUrl { + endpoint: "/ww/network-ok", + response_body: "workspace-network-ok", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_network_success(&result, "workspace-network-ok")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_on_failure_escalates_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::OnFailure; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_on_failure_escalates_outside_workspace"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval( + &test, + exec_command, + ReviewDecision::Approved, + Some("command failed; retry without sandbox?"), + ) + .await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("ww_on_failure.txt"), + "workspace-on-failure", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_unless_trusted_requires_approval_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::UnlessTrusted; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_unless_trusted_requires_approval_outside_workspace"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + let exec_command = _expected_command + .as_ref() + .expect("exec approval requires shell command"); + complete_with_exec_approval(&test, exec_command, ReviewDecision::Approved, None).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_created( + &test, + &result, + TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + "workspace-unless-trusted", + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_write_never_blocks_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy = workspace_write(false); + let approval_policy = AskForApproval::Never; + + let test = prepare_test( + &server, + approval_policy, + sandbox_policy.clone(), + false, + None, + ) + .await?; + + let call_id = "workspace_write_never_blocks_outside_workspace"; + let action = ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + content: "workspace-never", + }; + + let (event, _expected_command) = action.prepare(&test, &server, call_id, false).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, call_id, approval_policy, sandbox_policy.clone()).await?; + + complete_auto(&test).await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + assert_file_not_created( + &test, + &result, + TargetPath::OutsideWorkspace("ww_never.txt"), + if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["Permission denied|Operation not permitted|Read-only file system"] + }, + )?; Ok(()) }