mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## Why Credential brokerage must retain trusted destination hints even when shell environment policy hides them from child processes. Snapshot replay also needs to preserve credential aliases when tokens are short or captured dummy values change. ## What changed - Add `features.network_proxy.credentials` and preserve provider settings when toggling the proxy. Prevent project configuration from overriding providers or their credential and destination environment variables. - Use provider metadata throughout snapshot capture and replay, and pass hidden destination context to the broker without exposing it in the child environment. - Rewrite aliases containing short credentials or previously captured dummies, respecting environment filters and explicit overrides. - Preserve case-distinct `shell_environment_policy.set` keys so Windows credential ambiguity checks survive configuration merging and permission profile changes. - Restore independent MITM settings when credential brokerage is disabled. ## Testing Add regression coverage for custom providers, hidden destination hints, Bash and Zsh alias replay, configuration edits and merging, Windows ambiguity handling, and credential protection during approved execution. GitOrigin-RevId: b2bd935d2d8555be56baf8feb1a8e5b3a0c666bc
4954 lines
178 KiB
Rust
4954 lines
178 KiB
Rust
#![allow(clippy::unwrap_used)]
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_core::CodexThread;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::config::Constrained;
|
|
use codex_core::config::ThreadStoreConfig;
|
|
use codex_core::sandboxing::SandboxPermissions;
|
|
use codex_features::Feature;
|
|
use codex_models_manager::bundled_models_response;
|
|
use codex_protocol::approvals::NetworkApprovalProtocol;
|
|
use codex_protocol::approvals::NetworkPolicyAmendment;
|
|
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
#[cfg(unix)]
|
|
use codex_protocol::config_types::EnvironmentVariablePattern;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
|
use codex_protocol::protocol::ExecPolicyAmendment;
|
|
use codex_protocol::protocol::GranularApprovalConfig;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::managed_network_requirements_loader;
|
|
use core_test_support::responses::ev_apply_patch_custom_tool_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_function_call_with_namespace;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::mount_sse_once_match;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
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::local_selections;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::test_codex::turn_permission_fields;
|
|
use core_test_support::wait_for_event;
|
|
use core_test_support::wait_for_event_with_timeout;
|
|
use core_test_support::zsh_fork::build_zsh_fork_test;
|
|
use core_test_support::zsh_fork::restrictive_workspace_write_profile;
|
|
use core_test_support::zsh_fork::zsh_fork_runtime;
|
|
use core_test_support::zsh_fork::zsh_fork_test_builder;
|
|
use pretty_assertions::assert_eq;
|
|
use regex_lite::Regex;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::fs;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::Request;
|
|
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 = test.home.path().join(name);
|
|
(path.clone(), path.display().to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum ActionKind {
|
|
WriteFile {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
FetchUrlNoProxy {
|
|
endpoint: &'static str,
|
|
response_body: &'static str,
|
|
},
|
|
FetchUrl {
|
|
endpoint: &'static str,
|
|
response_body: &'static str,
|
|
},
|
|
RunCommand {
|
|
command: &'static str,
|
|
},
|
|
RunCommandWithPolicy {
|
|
command: &'static str,
|
|
policy_src: &'static str,
|
|
},
|
|
RunCommandWithPrefixRule {
|
|
command: &'static str,
|
|
prefix_rule: &'static [&'static str],
|
|
},
|
|
RunUnifiedExecCommand {
|
|
command: &'static str,
|
|
justification: Option<&'static str>,
|
|
},
|
|
ApplyPatchFreeform {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
ApplyPatchShell {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
}
|
|
|
|
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
|
|
"Requires escalated permissions to bypass the sandbox in tests.";
|
|
const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace"
|
|
"#;
|
|
|
|
impl ActionKind {
|
|
fn policy_src(&self) -> Option<&'static str> {
|
|
match self {
|
|
ActionKind::RunCommandWithPolicy { policy_src, .. } => Some(*policy_src),
|
|
ActionKind::WriteFile { .. }
|
|
| ActionKind::FetchUrlNoProxy { .. }
|
|
| ActionKind::FetchUrl { .. }
|
|
| ActionKind::RunCommand { .. }
|
|
| ActionKind::RunCommandWithPrefixRule { .. }
|
|
| ActionKind::RunUnifiedExecCommand { .. }
|
|
| ActionKind::ApplyPatchFreeform { .. }
|
|
| ActionKind::ApplyPatchShell { .. } => None,
|
|
}
|
|
}
|
|
|
|
async fn prepare(
|
|
&self,
|
|
test: &TestCodex,
|
|
server: &MockServer,
|
|
call_id: &str,
|
|
sandbox_permissions: SandboxPermissions,
|
|
) -> Result<(Value, Option<String>)> {
|
|
match self {
|
|
ActionKind::WriteFile { target, content } => {
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
let _ = fs::remove_file(&path);
|
|
let path_str = path.display().to_string();
|
|
let script = format!(
|
|
"from pathlib import Path; path = Path({path_str:?}); content = {content:?}; path.write_text(content, encoding='utf-8'); print(path.read_text(encoding='utf-8'), end='')",
|
|
);
|
|
let command = format!("python3 -c {script:?}");
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
sandbox_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 escaped_url = url.replace('\'', "\\'");
|
|
let script = format!(
|
|
"import sys\nimport urllib.request\nurl = '{escaped_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 = format!("python3 -c \"{script}\"");
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
sandbox_permissions,
|
|
)?;
|
|
Ok((event, Some(command)))
|
|
}
|
|
ActionKind::FetchUrlNoProxy {
|
|
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 escaped_url = url.replace('\'', "\\'");
|
|
let script = format!(
|
|
"import sys\nimport urllib.request\nurl = '{escaped_url}'\nopener = urllib.request.build_opener(urllib.request.ProxyHandler({{}}))\ntry:\n data = opener.open(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 = format!("python3 -c \"{script}\"");
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
sandbox_permissions,
|
|
)?;
|
|
Ok((event, Some(command)))
|
|
}
|
|
ActionKind::RunCommand { command } => {
|
|
// Bazel Linux runners can be heavily oversubscribed while this
|
|
// matrix runs, so avoid making scheduling latency look like an
|
|
// approval behavior failure.
|
|
let event = shell_event(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 30_000,
|
|
sandbox_permissions,
|
|
)?;
|
|
Ok((event, Some(command.to_string())))
|
|
}
|
|
ActionKind::RunCommandWithPolicy { command, .. } => {
|
|
// Bazel Linux runners can be heavily oversubscribed while this
|
|
// matrix runs, so avoid making scheduling latency look like an
|
|
// approval behavior failure.
|
|
let event = shell_event(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 30_000,
|
|
sandbox_permissions,
|
|
)?;
|
|
Ok((event, Some(command.to_string())))
|
|
}
|
|
ActionKind::RunCommandWithPrefixRule {
|
|
command,
|
|
prefix_rule,
|
|
} => {
|
|
let event = shell_event_with_prefix_rule(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 30_000,
|
|
sandbox_permissions,
|
|
Some(prefix_rule.iter().map(|part| (*part).to_string()).collect()),
|
|
)?;
|
|
Ok((event, Some(command.to_string())))
|
|
}
|
|
ActionKind::RunUnifiedExecCommand {
|
|
command,
|
|
justification,
|
|
} => {
|
|
let event = exec_command_event(
|
|
call_id,
|
|
command,
|
|
Some(1000),
|
|
sandbox_permissions,
|
|
*justification,
|
|
)?;
|
|
Ok((event, Some(command.to_string())))
|
|
}
|
|
ActionKind::ApplyPatchFreeform { 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_custom_tool_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);
|
|
// Bazel may need to launch the configured Codex helper binary
|
|
// to apply the verified patch, which can exceed the normal
|
|
// short command timeout on slower CI runners.
|
|
let timeout_ms = 30_000;
|
|
let event = shell_event(call_id, &command, timeout_ms, sandbox_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) -> 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");
|
|
script
|
|
}
|
|
|
|
fn shell_event(
|
|
call_id: &str,
|
|
command: &str,
|
|
timeout_ms: u64,
|
|
sandbox_permissions: SandboxPermissions,
|
|
) -> Result<Value> {
|
|
shell_event_with_prefix_rule(
|
|
call_id,
|
|
command,
|
|
timeout_ms,
|
|
sandbox_permissions,
|
|
/*prefix_rule*/ None,
|
|
)
|
|
}
|
|
|
|
fn shell_event_with_prefix_rule(
|
|
call_id: &str,
|
|
command: &str,
|
|
timeout_ms: u64,
|
|
sandbox_permissions: SandboxPermissions,
|
|
prefix_rule: Option<Vec<String>>,
|
|
) -> Result<Value> {
|
|
let mut args = json!({
|
|
"cmd": command,
|
|
"yield_time_ms": timeout_ms,
|
|
});
|
|
if sandbox_permissions.requests_sandbox_override() {
|
|
args["sandbox_permissions"] = json!(sandbox_permissions);
|
|
args["justification"] = json!(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
|
|
}
|
|
if let Some(prefix_rule) = prefix_rule {
|
|
args["prefix_rule"] = json!(prefix_rule);
|
|
}
|
|
let args_str = serde_json::to_string(&args)?;
|
|
Ok(ev_function_call(call_id, "exec_command", &args_str))
|
|
}
|
|
|
|
fn exec_command_event(
|
|
call_id: &str,
|
|
cmd: &str,
|
|
yield_time_ms: Option<u64>,
|
|
sandbox_permissions: SandboxPermissions,
|
|
justification: Option<&str>,
|
|
) -> 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);
|
|
}
|
|
if sandbox_permissions.requests_sandbox_override() {
|
|
args["sandbox_permissions"] = json!(sandbox_permissions);
|
|
let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
|
|
args["justification"] = json!(reason);
|
|
}
|
|
let args_str = serde_json::to_string(&args)?;
|
|
Ok(ev_function_call(call_id, "exec_command", &args_str))
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum Expectation {
|
|
FileCreated {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
FileCreatedNoExitCode {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
PatchApplied {
|
|
target: TargetPath,
|
|
content: &'static str,
|
|
},
|
|
FileNotCreated {
|
|
target: TargetPath,
|
|
message_contains: &'static [&'static str],
|
|
},
|
|
NetworkSuccess {
|
|
body_contains: &'static str,
|
|
},
|
|
NetworkSuccessNoExitCode {
|
|
body_contains: &'static str,
|
|
},
|
|
NetworkFailure {
|
|
expect_tag: &'static str,
|
|
},
|
|
CommandSuccess {
|
|
stdout_contains: &'static str,
|
|
},
|
|
CommandSuccessNoExitCode {
|
|
stdout_contains: &'static str,
|
|
},
|
|
CommandFailure {
|
|
output_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::FileCreatedNoExitCode { target, content } => {
|
|
let (path, _) = target.resolve_for_patch(test);
|
|
assert!(
|
|
result.exit_code.is_none() || result.exit_code == Some(0),
|
|
"expected no exit code 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::NetworkSuccessNoExitCode { body_contains } => {
|
|
assert!(
|
|
result.exit_code.is_none() || result.exit_code == Some(0),
|
|
"expected no exit code for successful network call: {}",
|
|
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 command exit: {}",
|
|
result.stdout
|
|
);
|
|
assert!(
|
|
result.stdout.contains(stdout_contains),
|
|
"command stdout missing {stdout_contains:?}: {}",
|
|
result.stdout
|
|
);
|
|
}
|
|
Expectation::CommandSuccessNoExitCode { stdout_contains } => {
|
|
assert!(
|
|
result.exit_code.is_none() || result.exit_code == Some(0),
|
|
"expected no exit code for command: {}",
|
|
result.stdout
|
|
);
|
|
assert!(
|
|
result.stdout.contains(stdout_contains),
|
|
"command stdout missing {stdout_contains:?}: {}",
|
|
result.stdout
|
|
);
|
|
}
|
|
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
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum ExpectedExecPolicyAmendment {
|
|
Prefix(&'static [&'static str]),
|
|
FullCommand,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum Outcome {
|
|
Auto,
|
|
ExecApproval {
|
|
decision: ReviewDecision,
|
|
expected_reason: Option<&'static str>,
|
|
},
|
|
ExecApprovalWithAmendment {
|
|
decision: ReviewDecision,
|
|
expected_reason: Option<&'static str>,
|
|
expected_execpolicy_amendment: Option<ExpectedExecPolicyAmendment>,
|
|
},
|
|
PatchApproval {
|
|
decision: ReviewDecision,
|
|
expected_reason: Option<&'static str>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ScenarioSpec {
|
|
name: &'static str,
|
|
approval_policy: AskForApproval,
|
|
sandbox_policy: SandboxPolicy,
|
|
action: ActionKind,
|
|
sandbox_permissions: SandboxPermissions,
|
|
features: Vec<Feature>,
|
|
model_override: Option<&'static str>,
|
|
outcome: Outcome,
|
|
expectation: Expectation,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ScenarioGroup {
|
|
DangerFullAccess,
|
|
ReadOnly,
|
|
WorkspaceWrite,
|
|
ApplyPatch,
|
|
UnifiedExec,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
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
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: prompt.into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: session_model,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn submit_turn_preserving_active_permission_profile(
|
|
test: &TestCodex,
|
|
prompt: &str,
|
|
approval_policy: AskForApproval,
|
|
) -> Result<()> {
|
|
let session_model = test.session_configured.model.clone();
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: prompt.into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: session_model,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_active_workspace_permission_profile(test: &TestCodex) {
|
|
assert_eq!(
|
|
test.session_configured
|
|
.active_permission_profile
|
|
.as_ref()
|
|
.map(|profile| profile.id.as_str()),
|
|
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
|
|
);
|
|
}
|
|
|
|
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 }
|
|
}
|
|
Err(_) => {
|
|
let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap();
|
|
let regex =
|
|
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
|
|
// parse freeform output
|
|
if let Some(captures) = structured.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 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(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn expect_exec_approval(
|
|
test: &TestCodex,
|
|
expected_command: &str,
|
|
) -> ExecApprovalRequestEvent {
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match event {
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
let last_arg = approval
|
|
.command
|
|
.last()
|
|
.map(std::string::String::as_str)
|
|
.unwrap_or_default();
|
|
assert_eq!(last_arg, expected_command);
|
|
approval
|
|
}
|
|
EventMsg::TurnComplete(_) => panic!("expected approval request before completion"),
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn expect_patch_approval(
|
|
test: &TestCodex,
|
|
expected_call_id: &str,
|
|
) -> ApplyPatchApprovalRequestEvent {
|
|
let event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ApplyPatchApprovalRequest(_)
|
|
| EventMsg::ExecApprovalRequest(_)
|
|
| EventMsg::Error(_)
|
|
| EventMsg::TurnAborted(_)
|
|
| EventMsg::ShutdownComplete
|
|
| EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
Duration::from_secs(15),
|
|
)
|
|
.await;
|
|
|
|
match event {
|
|
EventMsg::ApplyPatchApprovalRequest(approval) => {
|
|
assert_eq!(approval.call_id, expected_call_id);
|
|
approval
|
|
}
|
|
EventMsg::TurnComplete(_) => panic!("expected patch approval request before completion"),
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn wait_for_completion_without_approval(test: &TestCodex) {
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match event {
|
|
EventMsg::TurnComplete(_) => {}
|
|
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::TurnComplete(_))
|
|
})
|
|
.await;
|
|
}
|
|
|
|
fn body_contains(req: &Request, text: &str) -> bool {
|
|
let is_zstd = req
|
|
.headers
|
|
.get("content-encoding")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| {
|
|
value
|
|
.split(',')
|
|
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
|
|
});
|
|
let bytes = if is_zstd {
|
|
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
|
|
} else {
|
|
Some(req.body.clone())
|
|
};
|
|
bytes
|
|
.and_then(|body| String::from_utf8(body).ok())
|
|
.is_some_and(|body| body.contains(text))
|
|
}
|
|
|
|
async fn wait_for_spawned_thread(test: &TestCodex) -> Result<Arc<CodexThread>> {
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
let ids = test.thread_manager.list_thread_ids().await;
|
|
if let Some(thread_id) = ids
|
|
.iter()
|
|
.find(|id| **id != test.session_configured.thread_id)
|
|
{
|
|
return test
|
|
.thread_manager
|
|
.get_thread(*thread_id)
|
|
.await
|
|
.map_err(anyhow::Error::from);
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
anyhow::bail!("timed out waiting for spawned thread");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
}
|
|
|
|
fn scenarios() -> Vec<ScenarioSpec> {
|
|
use AskForApproval::*;
|
|
|
|
let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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_outside_write_gpt_5_1_no_exit",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"),
|
|
content: "danger-on-request",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileCreated {
|
|
target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"),
|
|
content: "danger-on-request",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "danger_full_access_on_request_allows_network",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::FetchUrlNoProxy {
|
|
endpoint: "/dfa/network",
|
|
response_body: "danger-network-ok",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::NetworkSuccess {
|
|
body_contains: "danger-network-ok",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "danger_full_access_on_request_allows_network_gpt_5_1_no_exit",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::FetchUrlNoProxy {
|
|
endpoint: "/dfa/network",
|
|
response_body: "danger-network-ok",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::NetworkSuccessNoExitCode {
|
|
body_contains: "danger-network-ok",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::RunCommand {
|
|
command: "echo trusted-unless",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("blocked in untrusted project"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "blocked in untrusted project",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_unless_trusted_requires_approval_gpt_5_1_no_exit",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::RunCommand {
|
|
command: "echo trusted-unless",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("blocked in untrusted project"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "blocked in untrusted project",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "cat_redirect_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: r#"cat < "hello" > /var/test.txt"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("blocked by distinctive approval policy"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "blocked by distinctive approval policy",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "cat_redirect_on_request_requires_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: r#"cat < "hello" > /var/test.txt"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_escalation_on_request_requires_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo known-safe-escalation",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApprovalWithAmendment {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[
|
|
"echo",
|
|
"known-safe-escalation",
|
|
])),
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_escalation_granular_sandbox_enabled_requires_approval",
|
|
approval_policy: Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}),
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo known-safe-escalation",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApprovalWithAmendment {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[
|
|
"echo",
|
|
"known-safe-escalation",
|
|
])),
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_escalation_granular_sandbox_disabled_rejects",
|
|
approval_policy: Granular(GranularApprovalConfig {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}),
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo known-safe-escalation-granular-disabled",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "you cannot ask for escalated permissions",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "cat_heredoc_inner_allow_rule_requires_escalation_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPolicy {
|
|
command: "cat <<'EOF'\nhello\nEOF",
|
|
policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApprovalWithAmendment {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand),
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "cat_heredoc_file_redirect_prefix_rule_requires_escalation_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPrefixRule {
|
|
command: r#"cat <<'EOF' > /tmp/out.txt
|
|
hello
|
|
EOF"#,
|
|
prefix_rule: &["cat"],
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "cat_heredoc_variable_assignment_policy_requires_escalation_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPolicy {
|
|
command: r#"PATH=/tmp/evil:$PATH cat <<'EOF'
|
|
hello
|
|
EOF"#,
|
|
policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "python_heredoc_requested_prefix_rule_proposes_full_command",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPrefixRule {
|
|
command: r#"python3 <<'PY'
|
|
print('hello')
|
|
PY"#,
|
|
prefix_rule: &["python3"],
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApprovalWithAmendment {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand),
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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_unless_trusted_requests_approval_gpt_5_1_no_exit",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"),
|
|
content: "danger-unless-trusted",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::FileCreatedNoExitCode {
|
|
target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileCreated {
|
|
target: TargetPath::OutsideWorkspace("dfa_never.txt"),
|
|
content: "danger-never",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "danger_full_access_never_allows_outside_write_gpt_5_1_no_exit",
|
|
approval_policy: Never,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"),
|
|
content: "danger-never",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileCreatedNoExitCode {
|
|
target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"),
|
|
content: "danger-never",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "read_only_on_request_requires_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::Workspace("ro_on_request.txt"),
|
|
content: "read-only-approval",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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: "read_only_on_request_requires_approval_gpt_5_1_no_exit",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
|
|
content: "read-only-approval",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::FileCreatedNoExitCode {
|
|
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
|
|
content: "read-only-approval",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_on_request_read_only_runs_without_prompt",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo trusted-read-only",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::CommandSuccess {
|
|
stdout_contains: "trusted-read-only",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo trusted-read-only",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::CommandSuccessNoExitCode {
|
|
stdout_contains: "trusted-read-only",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "read_only_on_request_blocks_network",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::FetchUrl {
|
|
endpoint: "/ro/network-blocked",
|
|
response_body: "should-not-see",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
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::new_read_only_policy(),
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::Workspace("ro_on_request_denied.txt"),
|
|
content: "should-not-write",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: None,
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::FileNotCreated {
|
|
target: TargetPath::Workspace("ro_on_request_denied.txt"),
|
|
message_contains: &["rejected by user"],
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "read_only_on_request_network_escalates_when_approved",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::FetchUrl {
|
|
endpoint: "/ro/network-approved",
|
|
response_body: "read-only-network-ok",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::NetworkSuccess {
|
|
body_contains: "read-only-network-ok",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::FetchUrl {
|
|
endpoint: "/ro/network-approved",
|
|
response_body: "read-only-network-ok",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::NetworkSuccessNoExitCode {
|
|
body_contains: "read-only-network-ok",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_exec_command_requires_patch_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::ApplyPatchShell {
|
|
target: TargetPath::Workspace("apply_patch_shell.txt"),
|
|
content: "shell-apply-patch",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
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_freeform_auto_inside_workspace",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::Workspace("apply_patch_freeform.txt"),
|
|
content: "freeform-apply-patch",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::PatchApplied {
|
|
target: TargetPath::Workspace("apply_patch_freeform.txt"),
|
|
content: "freeform-apply-patch",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_freeform_danger_allows_outside_workspace",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"),
|
|
content: "freeform-patch-danger",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::PatchApplied {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"),
|
|
content: "freeform-patch-danger",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_freeform_outside_requires_patch_approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"),
|
|
content: "freeform-patch-outside",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::PatchApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::PatchApplied {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"),
|
|
content: "freeform-patch-outside",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_freeform_outside_denied_blocks_patch",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"),
|
|
content: "freeform-patch-outside-denied",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::PatchApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::FileNotCreated {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"),
|
|
message_contains: &["patch rejected by user"],
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_exec_command_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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
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_freeform_unless_trusted_requires_patch_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"),
|
|
content: "freeform-patch-unless-trusted",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::PatchApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::PatchApplied {
|
|
target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"),
|
|
content: "freeform-patch-unless-trusted",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "apply_patch_freeform_never_rejects_outside_workspace",
|
|
approval_policy: Never,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::ApplyPatchFreeform {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"),
|
|
content: "freeform-patch-never",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.4"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileNotCreated {
|
|
target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"),
|
|
message_contains: &[
|
|
"patch rejected: writing outside of the project; rejected by user approval settings",
|
|
],
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "read_only_never_reports_sandbox_failure",
|
|
approval_policy: Never,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::WriteFile {
|
|
target: TargetPath::Workspace("ro_never.txt"),
|
|
content: "read-only-never",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: None,
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileNotCreated {
|
|
target: TargetPath::Workspace("ro_never.txt"),
|
|
message_contains: if cfg!(target_os = "linux") {
|
|
&["Permission denied|Read-only file system"]
|
|
} else {
|
|
&[
|
|
"Permission denied|Operation not permitted|operation not permitted|\
|
|
Read-only file system",
|
|
]
|
|
},
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "simple_command_never_runs_without_prompt",
|
|
approval_policy: Never,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::RunCommand {
|
|
command: "echo trusted-never",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::NetworkSuccess {
|
|
body_contains: "workspace-network-ok",
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: None,
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::FileNotCreated {
|
|
target: TargetPath::OutsideWorkspace("ww_never.txt"),
|
|
message_contains: if cfg!(target_os = "linux") {
|
|
&["Permission denied|Read-only file system"]
|
|
} else {
|
|
&[
|
|
"Permission denied|Operation not permitted|operation not permitted|\
|
|
Read-only file system",
|
|
]
|
|
},
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "unified exec on request no approval for simple command",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
command: "echo \"hello unified exec\"",
|
|
justification: None,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![Feature::UnifiedExec],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::Auto,
|
|
expectation: Expectation::CommandSuccess {
|
|
stdout_contains: "hello unified exec",
|
|
},
|
|
},
|
|
#[cfg(not(all(target_os = "linux", target_arch = "aarch64")))]
|
|
// Linux sandbox arg0 test workaround doesn't work on ARM
|
|
ScenarioSpec {
|
|
name: "unified exec on request escalated requires approval",
|
|
approval_policy: OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
|
|
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![Feature::UnifiedExec],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::Approved,
|
|
expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
|
|
},
|
|
expectation: Expectation::CommandSuccess {
|
|
stdout_contains: "escalated 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",
|
|
justification: None,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![Feature::UnifiedExec],
|
|
model_override: None,
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "heredoc with redirect still requires approval",
|
|
approval_policy: AskForApproval::OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF",
|
|
justification: None,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![Feature::UnifiedExec],
|
|
model_override: None,
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "compound command with one safe command still requires approval",
|
|
approval_policy: AskForApproval::OnRequest,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunUnifiedExecCommand {
|
|
command: "cat ./one.txt && touch ./two.txt",
|
|
justification: None,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
|
features: vec![Feature::UnifiedExec],
|
|
model_override: None,
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected by user",
|
|
},
|
|
},
|
|
// Deny these commands before execution. The nonexistent find target also
|
|
// keeps a regression from deleting anything in the test workspace.
|
|
ScenarioSpec {
|
|
name: "find_brace_expansion_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "find ./missing-approval-target -{delete,print}",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "rg_brace_expansion_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "rg --pre{=,=sh} pattern missing-approval-payload.sh",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "brace_expansion_literal_allow_rule_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPolicy {
|
|
command: "find ./missing-approval-target -{delete,print}",
|
|
policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-{delete,print}"], decision="allow")"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "rg_brace_expansion_literal_allow_rule_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPolicy {
|
|
command: "rg --pre{=,=sh} pattern missing-approval-payload.sh",
|
|
policy_src: r#"prefix_rule(pattern=["rg", "--pre{=,=sh}"], decision="allow")"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "find_glob_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "find ./missing-approval-target -del*",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "find_escape_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: r"find ./missing-approval-target -de\lete",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "find_quoted_escape_unless_trusted_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: "find ./missing-approval-target \"-de\\\nlete\"",
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "heredoc_glob_literal_allow_rule_requires_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommandWithPolicy {
|
|
command: "find ./missing-approval-target -del* <<'EOF'\nEOF",
|
|
policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-del*"], decision="allow")"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("rejected dynamic shell word"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "rejected dynamic shell word",
|
|
},
|
|
},
|
|
ScenarioSpec {
|
|
name: "quoted_shell_metacharacters_unless_trusted_require_approval",
|
|
approval_policy: UnlessTrusted,
|
|
sandbox_policy: workspace_write(false),
|
|
action: ActionKind::RunCommand {
|
|
command: r#"echo -g"*.py" '-{delete,print}'"#,
|
|
},
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
features: vec![],
|
|
model_override: Some("gpt-5.2"),
|
|
outcome: Outcome::ExecApproval {
|
|
decision: ReviewDecision::denied("blocked in untrusted project"),
|
|
expected_reason: None,
|
|
},
|
|
expectation: Expectation::CommandFailure {
|
|
output_contains: "blocked in untrusted project",
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
#[test_case(ScenarioGroup::DangerFullAccess ; "danger_full_access")]
|
|
#[test_case(ScenarioGroup::ReadOnly ; "read_only")]
|
|
#[test_case(ScenarioGroup::WorkspaceWrite ; "workspace_write")]
|
|
#[test_case(ScenarioGroup::ApplyPatch ; "apply_patch")]
|
|
#[test_case(ScenarioGroup::UnifiedExec ; "unified_exec")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn approval_matrix_covers_group(group: ScenarioGroup) -> Result<()> {
|
|
run_scenario_group(group).await
|
|
}
|
|
|
|
async fn run_scenario_group(group: ScenarioGroup) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let scenarios = scenarios()
|
|
.into_iter()
|
|
.filter(|scenario| scenario_group(scenario) == group)
|
|
.collect::<Vec<_>>();
|
|
assert!(!scenarios.is_empty(), "expected scenarios for {group:?}");
|
|
|
|
for scenario in scenarios {
|
|
run_scenario(&scenario)
|
|
.await
|
|
.with_context(|| format!("approval scenario failed: {}", scenario.name))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup {
|
|
match &scenario.action {
|
|
ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => {
|
|
ScenarioGroup::ApplyPatch
|
|
}
|
|
ActionKind::RunUnifiedExecCommand { .. } => ScenarioGroup::UnifiedExec,
|
|
ActionKind::WriteFile { .. }
|
|
| ActionKind::FetchUrlNoProxy { .. }
|
|
| ActionKind::FetchUrl { .. }
|
|
| ActionKind::RunCommand { .. }
|
|
| ActionKind::RunCommandWithPolicy { .. }
|
|
| ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy {
|
|
SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess,
|
|
SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly,
|
|
SandboxPolicy::WorkspaceWrite { .. } => ScenarioGroup::WorkspaceWrite,
|
|
SandboxPolicy::ExternalSandbox { .. } => ScenarioGroup::WorkspaceWrite,
|
|
},
|
|
}
|
|
}
|
|
|
|
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 features = scenario.features.clone();
|
|
let model_override = scenario.model_override;
|
|
let model = model_override.unwrap_or("gpt-5.4");
|
|
let policy_src = scenario.action.policy_src();
|
|
let thread_store_id = format!("approval-scenario-{}", scenario.name);
|
|
let model_catalog = bundled_models_response()?;
|
|
|
|
let mut builder = test_codex().with_model(model).with_config(move |config| {
|
|
config.model_catalog = Some(model_catalog);
|
|
// These scenarios assert tool behavior, not rollout persistence.
|
|
config.experimental_thread_store = ThreadStoreConfig::InMemory {
|
|
id: thread_store_id,
|
|
};
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy.clone())
|
|
.expect("set sandbox policy");
|
|
for feature in features {
|
|
config
|
|
.features
|
|
.enable(feature)
|
|
.expect("test config should allow feature update");
|
|
}
|
|
});
|
|
if let Some(policy_src) = policy_src {
|
|
builder = builder.with_pre_build_hook(move |home| {
|
|
let rules_dir = home.join("rules");
|
|
fs::create_dir_all(&rules_dir).expect("create rules dir");
|
|
fs::write(rules_dir.join("default.rules"), policy_src).expect("write policy");
|
|
});
|
|
}
|
|
let test = builder.build(&server).await?;
|
|
|
|
let call_id = scenario.name;
|
|
let (event, expected_command) = scenario
|
|
.action
|
|
.prepare(&test, &server, call_id, scenario.sandbox_permissions)
|
|
.await?;
|
|
if let Some(command) = expected_command.as_deref() {
|
|
eprintln!("approval scenario {} command: {command}", scenario.name);
|
|
}
|
|
|
|
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_deref()
|
|
.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: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: decision.clone(),
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
}
|
|
Outcome::ExecApprovalWithAmendment {
|
|
decision,
|
|
expected_reason,
|
|
expected_execpolicy_amendment,
|
|
} => {
|
|
let command = expected_command
|
|
.as_deref()
|
|
.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
|
|
);
|
|
}
|
|
let expected_execpolicy_amendment =
|
|
expected_execpolicy_amendment.as_ref().map(|expected| {
|
|
ExecPolicyAmendment::new(match expected {
|
|
ExpectedExecPolicyAmendment::Prefix(command) => {
|
|
command.iter().map(|part| (*part).to_string()).collect()
|
|
}
|
|
ExpectedExecPolicyAmendment::FullCommand => approval.command.clone(),
|
|
})
|
|
});
|
|
assert_eq!(
|
|
approval.proposed_execpolicy_amendment, expected_execpolicy_amendment,
|
|
"unexpected execpolicy amendment for {}",
|
|
scenario.name
|
|
);
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: decision.clone(),
|
|
})
|
|
.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: approval.call_id,
|
|
decision: decision.clone(),
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
}
|
|
}
|
|
|
|
let output_request = results_mock.single_request();
|
|
let output_item = if matches!(scenario.action, ActionKind::ApplyPatchFreeform { .. }) {
|
|
output_request.custom_tool_call_output(call_id)
|
|
} else {
|
|
output_request.function_call_output(call_id)
|
|
};
|
|
let result = parse_result(&output_item);
|
|
eprintln!(
|
|
"approval scenario {} result: exit_code={:?} stdout={:?}",
|
|
scenario.name, result.exit_code, result.stdout
|
|
);
|
|
let verification_result = scenario.expectation.verify(&test, &result);
|
|
test.codex.shutdown_and_wait().await?;
|
|
verification_result
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::UnlessTrusted;
|
|
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
|
|
let mut builder = test_codex()
|
|
.with_model("gpt-5.4")
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let target = TargetPath::OutsideWorkspace("apply_patch_allow_session.txt");
|
|
let (path, patch_path) = target.resolve_for_patch(&test);
|
|
let _path_cleanup = tempfile::TempPath::try_from_path(path.clone())?;
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let patch_add = build_add_file_patch(&patch_path, "before");
|
|
let patch_update = format!(
|
|
"*** Begin Patch\n*** Update File: {patch_path}\n@@\n-before\n+after\n*** End Patch\n"
|
|
);
|
|
|
|
let call_id_1 = "apply_patch_allow_session_1";
|
|
let call_id_2 = "apply_patch_allow_session_2";
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id_1, &patch_add),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"apply_patch allow session",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
let approval = expect_patch_approval(&test, call_id_1).await;
|
|
test.codex
|
|
.submit(Op::PatchApproval {
|
|
id: approval.call_id,
|
|
decision: ReviewDecision::ApprovedForSession,
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
assert!(fs::read_to_string(&path)?.contains("before"));
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_apply_patch_custom_tool_call(call_id_2, &patch_update),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
)
|
|
.await;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-2", "done"),
|
|
ev_completed("resp-4"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"apply_patch allow session followup",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
match event {
|
|
EventMsg::TurnComplete(_) => {}
|
|
EventMsg::ApplyPatchApprovalRequest(event) => {
|
|
panic!("unexpected patch approval request: {:?}", event.call_id)
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
|
|
assert!(fs::read_to_string(&path)?.contains("after"));
|
|
let _ = fs::remove_file(path);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn approving_execpolicy_amendment_does_not_reinject_permissions_instructions() -> Result<()> {
|
|
assert_execpolicy_amendment_context(PermissionsInstructionsExpectation::Included).await
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn approving_execpolicy_amendment_is_reported_when_permissions_are_disabled() -> Result<()> {
|
|
assert_execpolicy_amendment_context(PermissionsInstructionsExpectation::Omitted).await
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PermissionsInstructionsExpectation {
|
|
Included,
|
|
Omitted,
|
|
}
|
|
|
|
async fn assert_execpolicy_amendment_context(
|
|
expectation: PermissionsInstructionsExpectation,
|
|
) -> Result<()> {
|
|
let (include_permissions_instructions, expected_permissions_block_count) = match expectation {
|
|
PermissionsInstructionsExpectation::Included => (true, 1),
|
|
PermissionsInstructionsExpectation::Omitted => (false, 0),
|
|
};
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.4", |model_info| {
|
|
model_info.model_messages = None;
|
|
})
|
|
.with_config(move |config| {
|
|
config.include_permissions_instructions = include_permissions_instructions;
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.features
|
|
.disable(Feature::DeferredExecutor)
|
|
.expect("test config should allow feature update");
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let call_id = "permissions-prefix";
|
|
let (event, expected_command) = ActionKind::RunCommand {
|
|
command: "touch permissions-prefix.txt",
|
|
}
|
|
.prepare(
|
|
&test,
|
|
&server,
|
|
call_id,
|
|
SandboxPermissions::RequireEscalated,
|
|
)
|
|
.await?;
|
|
let expected_command =
|
|
expected_command.expect("execpolicy amendment scenario should produce a shell command");
|
|
let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![
|
|
"touch".to_string(),
|
|
"permissions-prefix.txt".to_string(),
|
|
]);
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-permissions-prefix-1"),
|
|
event,
|
|
ev_completed("resp-permissions-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let follow_up = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-permissions-prefix", "done"),
|
|
ev_completed("resp-permissions-prefix-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(&test, "permissions-prefix", approval_policy, sandbox_policy).await?;
|
|
|
|
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
|
|
assert_eq!(
|
|
approval.proposed_execpolicy_amendment,
|
|
Some(expected_execpolicy_amendment.clone())
|
|
);
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment: expected_execpolicy_amendment,
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
let developer_messages = follow_up.single_request().message_input_texts("developer");
|
|
let permissions_block_count = developer_messages
|
|
.iter()
|
|
.map(|message| message.matches("<permissions instructions>").count())
|
|
.sum::<usize>();
|
|
assert_eq!(
|
|
permissions_block_count, expected_permissions_block_count,
|
|
"saving an execpolicy amendment must respect the full permissions rendering mode: \
|
|
{developer_messages:#?}"
|
|
);
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message.contains("Approved command prefix saved:")
|
|
&& message.contains(r#"["touch", "permissions-prefix.txt"]"#)),
|
|
"expected developer message documenting saved rule, got: {developer_messages:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::UnlessTrusted;
|
|
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
let allow_prefix_path = test.cwd.path().join("allow-prefix.txt");
|
|
let _ = fs::remove_file(&allow_prefix_path);
|
|
|
|
let call_id_first = "allow-prefix-first";
|
|
let (first_event, expected_command) = ActionKind::RunCommand {
|
|
command: "touch allow-prefix.txt",
|
|
}
|
|
.prepare(
|
|
&test,
|
|
&server,
|
|
call_id_first,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await?;
|
|
let expected_command =
|
|
expected_command.expect("execpolicy amendment scenario should produce a shell command");
|
|
let expected_execpolicy_amendment =
|
|
ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]);
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-allow-prefix-1"),
|
|
first_event,
|
|
ev_completed("resp-allow-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let first_results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-allow-prefix-1", "done"),
|
|
ev_completed("resp-allow-prefix-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"allow-prefix-first",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
|
|
assert_eq!(
|
|
approval.proposed_execpolicy_amendment,
|
|
Some(expected_execpolicy_amendment.clone())
|
|
);
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
let developer_messages = first_results
|
|
.single_request()
|
|
.message_input_texts("developer");
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message.contains(r#"["touch", "allow-prefix.txt"]"#)),
|
|
"expected developer message documenting saved rule, got: {developer_messages:?}"
|
|
);
|
|
|
|
let policy_path = test.home.path().join("rules").join("default.rules");
|
|
let policy_contents = fs::read_to_string(&policy_path)?;
|
|
assert!(
|
|
policy_contents
|
|
.contains(r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#),
|
|
"unexpected policy contents: {policy_contents}"
|
|
);
|
|
|
|
let first_output = parse_result(
|
|
&first_results
|
|
.single_request()
|
|
.function_call_output(call_id_first),
|
|
);
|
|
assert_eq!(first_output.exit_code.unwrap_or(0), 0);
|
|
assert!(
|
|
first_output.stdout.is_empty(),
|
|
"unexpected stdout: {}",
|
|
first_output.stdout
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&allow_prefix_path)?,
|
|
"",
|
|
"unexpected file contents after first run"
|
|
);
|
|
|
|
let call_id_second = "allow-prefix-second";
|
|
let (second_event, second_command) = ActionKind::RunCommand {
|
|
command: "touch allow-prefix.txt",
|
|
}
|
|
.prepare(
|
|
&test,
|
|
&server,
|
|
call_id_second,
|
|
SandboxPermissions::UseDefault,
|
|
)
|
|
.await?;
|
|
assert_eq!(second_command.as_deref(), Some(expected_command.as_str()));
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-allow-prefix-3"),
|
|
second_event,
|
|
ev_completed("resp-allow-prefix-3"),
|
|
]),
|
|
)
|
|
.await;
|
|
let second_results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-allow-prefix-2", "done"),
|
|
ev_completed("resp-allow-prefix-4"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"allow-prefix-second",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let second_output = parse_result(
|
|
&second_results
|
|
.single_request()
|
|
.function_call_output(call_id_second),
|
|
);
|
|
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
|
|
assert!(
|
|
second_output.stdout.is_empty(),
|
|
"unexpected stdout: {}",
|
|
second_output.stdout
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&allow_prefix_path)?,
|
|
"",
|
|
"unexpected file contents after second run"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::UnlessTrusted;
|
|
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
config
|
|
.features
|
|
.enable(Feature::Collab)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
const PARENT_PROMPT: &str = "spawn a child that repeats a command";
|
|
const CHILD_PROMPT: &str = "run the same command twice";
|
|
const SPAWN_CALL_ID: &str = "spawn-child-1";
|
|
const CHILD_CALL_ID_1: &str = "child-touch-1";
|
|
const PARENT_CALL_ID_2: &str = "parent-touch-2";
|
|
|
|
let child_file = test.cwd.path().join("subagent-allow-prefix.txt");
|
|
let _ = fs::remove_file(&child_file);
|
|
|
|
let spawn_args = serde_json::to_string(&json!({
|
|
"message": CHILD_PROMPT,
|
|
}))?;
|
|
mount_sse_once_match(
|
|
&server,
|
|
|req: &Request| body_contains(req, PARENT_PROMPT),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-1"),
|
|
ev_function_call_with_namespace(
|
|
SPAWN_CALL_ID,
|
|
"multi_agent_v1",
|
|
"spawn_agent",
|
|
&spawn_args,
|
|
),
|
|
ev_completed("resp-parent-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let child_cmd_args = serde_json::to_string(&json!({
|
|
"cmd": "touch subagent-allow-prefix.txt",
|
|
"yield_time_ms": 10_000,
|
|
"prefix_rule": ["touch", "subagent-allow-prefix.txt"],
|
|
}))?;
|
|
mount_sse_once_match(
|
|
&server,
|
|
|req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID),
|
|
sse(vec![
|
|
ev_response_created("resp-child-1"),
|
|
ev_function_call(CHILD_CALL_ID_1, "exec_command", &child_cmd_args),
|
|
ev_completed("resp-child-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
mount_sse_once_match(
|
|
&server,
|
|
|req: &Request| body_contains(req, CHILD_CALL_ID_1),
|
|
sse(vec![
|
|
ev_response_created("resp-child-2"),
|
|
ev_assistant_message("msg-child-2", "child done"),
|
|
ev_completed("resp-child-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
mount_sse_once_match(
|
|
&server,
|
|
|req: &Request| body_contains(req, SPAWN_CALL_ID),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-2"),
|
|
ev_assistant_message("msg-parent-2", "parent done"),
|
|
ev_completed("resp-parent-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-parent-3"),
|
|
ev_function_call(PARENT_CALL_ID_2, "exec_command", &child_cmd_args),
|
|
ev_completed("resp-parent-3"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-parent-4"),
|
|
ev_assistant_message("msg-parent-4", "parent rerun done"),
|
|
ev_completed("resp-parent-4"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
PARENT_PROMPT,
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let child = wait_for_spawned_thread(&test).await?;
|
|
let approval_event = wait_for_event_with_timeout(
|
|
&child,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
Duration::from_secs(2),
|
|
)
|
|
.await;
|
|
|
|
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
|
|
panic!("expected child approval before completion");
|
|
};
|
|
let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![
|
|
"touch".to_string(),
|
|
"subagent-allow-prefix.txt".to_string(),
|
|
]);
|
|
assert_eq!(
|
|
approval.proposed_execpolicy_amendment,
|
|
Some(expected_execpolicy_amendment.clone())
|
|
);
|
|
|
|
child
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment: expected_execpolicy_amendment,
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let child_event = wait_for_event_with_timeout(
|
|
&child,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
Duration::from_secs(2),
|
|
)
|
|
.await;
|
|
match child_event {
|
|
EventMsg::TurnComplete(_) => {}
|
|
EventMsg::ExecApprovalRequest(ev) => {
|
|
panic!("unexpected second child approval request: {:?}", ev.command)
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
assert!(
|
|
child_file.exists(),
|
|
"expected subagent command to create file"
|
|
);
|
|
fs::remove_file(&child_file)?;
|
|
assert!(
|
|
!child_file.exists(),
|
|
"expected child file to be removed before parent rerun"
|
|
);
|
|
|
|
submit_turn(
|
|
&test,
|
|
"parent reruns child command",
|
|
approval_policy,
|
|
sandbox_policy,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case("zsh_fork", false, false; "zsh_fork_unsupported")]
|
|
#[test_case("direct", false, false; "explicit_zsh")]
|
|
#[test_case("direct", true, false; "non_login_in_login_enabled_session")]
|
|
#[test_case("direct", true, true; "login_startup")]
|
|
#[test_case("filtered_startup", false, false; "filtered_global_startup")]
|
|
#[test_case("startup_override", false, false; "global_startup_overrides")]
|
|
#[test_case("additional_permissions", false, false; "filtered_host_additional_permissions")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn shell_startup_credentials_are_brokered(
|
|
shell_mode: &'static str,
|
|
allow_login_shell: bool,
|
|
login: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
const GH_HOST: &str = "github.example.com";
|
|
const REAL_GITHUB_TOKEN: &str =
|
|
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
const CUSTOM_HOST: &str = "api.custom.example";
|
|
const REAL_CUSTOM_TOKEN: &str = "custom_abcdefghijklmnopqrstuvwx";
|
|
let request_extra_permissions = shell_mode == "additional_permissions";
|
|
let approval_policy = if request_extra_permissions {
|
|
AskForApproval::OnRequest
|
|
} else {
|
|
AskForApproval::Never
|
|
};
|
|
let global_startup = matches!(shell_mode, "filtered_startup" | "startup_override");
|
|
let direct = shell_mode == "direct" || global_startup || request_extra_permissions;
|
|
|
|
let builder = if shell_mode == "zsh_fork" {
|
|
let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else {
|
|
return Ok(());
|
|
};
|
|
zsh_fork_test_builder(runtime, AskForApproval::Never)
|
|
} else {
|
|
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
|
|
return Ok(());
|
|
};
|
|
test_codex()
|
|
.with_user_shell(zsh)
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config.features.enable(Feature::ShellTool).unwrap();
|
|
})
|
|
};
|
|
|
|
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let outside_path = outside_dir.path().join("unsandboxed-snapshot-write");
|
|
let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
|
|
let home = Arc::new(TempDir::new()?);
|
|
let startup_dir = home.path().join("startup");
|
|
fs::create_dir(&startup_dir)?;
|
|
let untrusted_snapshot_condition = r#"[[ "$ZSH_EXECUTION_STRING" == *"command env -0"* ]]"#;
|
|
fs::write(
|
|
startup_dir.join(".zshenv"),
|
|
format!(
|
|
"export GH_HOST='{GH_HOST}'\n\
|
|
if [[ ! -o login ]]; then export GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'; fi\n\
|
|
export CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\n\
|
|
export CUSTOM_HOST='{CUSTOM_HOST}'\n\
|
|
export APP_SETTING EXCLUDED_SETTING\n\
|
|
export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n\
|
|
export BROKERED_STARTUP_CWD=\"$PWD\"\n\
|
|
if {untrusted_snapshot_condition}; then\n\
|
|
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
|
|
fi\n\
|
|
function setopt() {{\n\
|
|
builtin setopt \"$@\"\n\
|
|
if {untrusted_snapshot_condition}; then\n\
|
|
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
|
|
fi\n\
|
|
}}\n"
|
|
),
|
|
)?;
|
|
fs::write(
|
|
startup_dir.join(".zshrc"),
|
|
format!(
|
|
"export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n\
|
|
export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n"
|
|
),
|
|
)?;
|
|
fs::write(
|
|
startup_dir.join(".zprofile"),
|
|
"export LOGIN_SHELL_READY=ready\n",
|
|
)?;
|
|
let startup_shell = if global_startup {
|
|
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
|
|
return Ok(());
|
|
};
|
|
let zsh_path = zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone();
|
|
let path = startup_dir.join("zsh");
|
|
fs::write(
|
|
&path,
|
|
format!(
|
|
"#!/bin/sh\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\nexport CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\nexport AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\nexec {} \"$@\"\n",
|
|
shlex::try_quote(&zsh_path)?
|
|
),
|
|
)?;
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o700))?;
|
|
Some(path)
|
|
} else {
|
|
None
|
|
};
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
format!(
|
|
r#"default_permissions = "brokered"
|
|
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
|
|
|
|
[features.network_proxy]
|
|
enabled = true
|
|
credential_broker = true
|
|
|
|
[features.network_proxy.credentials.custom]
|
|
env = ["CUSTOM_API_KEY"]
|
|
patterns = ["custom_[a-z]{{24}}"]
|
|
url_prefix_from_env = "CUSTOM_HOST"
|
|
|
|
[shell_environment_policy.set]
|
|
ZDOTDIR = "{}"
|
|
"#,
|
|
startup_dir.display()
|
|
),
|
|
)?;
|
|
let mut builder = builder
|
|
.with_home(home)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader());
|
|
if direct && !allow_login_shell {
|
|
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
|
|
return Ok(());
|
|
};
|
|
builder = builder.with_user_shell(bash);
|
|
}
|
|
let mut builder = builder.with_config(move |config| {
|
|
config.permissions.allow_login_shell = allow_login_shell;
|
|
config.permissions.shell_environment_policy.exclude.push(
|
|
EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_SETTING"),
|
|
);
|
|
if request_extra_permissions {
|
|
config
|
|
.features
|
|
.enable(Feature::ExecPermissionApprovals)
|
|
.unwrap();
|
|
config
|
|
.features
|
|
.enable(Feature::RequestPermissionsTool)
|
|
.unwrap();
|
|
config.permissions.shell_environment_policy.exclude.push(
|
|
EnvironmentVariablePattern::new_case_insensitive("CUSTOM_HOST"),
|
|
);
|
|
} else if shell_mode == "filtered_startup" {
|
|
config.permissions.shell_environment_policy.exclude.extend([
|
|
EnvironmentVariablePattern::new_case_insensitive("GH_ENTERPRISE_TOKEN"),
|
|
EnvironmentVariablePattern::new_case_insensitive("CUSTOM_API_KEY"),
|
|
]);
|
|
} else if shell_mode == "startup_override" {
|
|
config.permissions.shell_environment_policy.r#set.extend([
|
|
("GH_ENTERPRISE_TOKEN".to_string(), String::new()),
|
|
("CUSTOM_API_KEY".to_string(), String::new()),
|
|
("AUTH_HEADER".to_string(), "enterprise-header".to_string()),
|
|
]);
|
|
}
|
|
config
|
|
.features
|
|
.enable(Feature::ShellSnapshot)
|
|
.expect("test config should allow ShellSnapshot override");
|
|
if direct {
|
|
config
|
|
.features
|
|
.disable(Feature::ShellZshFork)
|
|
.expect("test config should allow direct shell execution");
|
|
}
|
|
});
|
|
let server = start_mock_server().await;
|
|
let test = builder.build(&server).await?;
|
|
let snapshot_dir = test.home.path().join("shell_snapshots");
|
|
let workdir = if allow_login_shell || shell_mode == "zsh_fork" {
|
|
test.cwd.path().to_path_buf()
|
|
} else {
|
|
let workdir = test.cwd.path().join("brokered-subdirectory");
|
|
fs::create_dir(&workdir)?;
|
|
workdir
|
|
};
|
|
let call_id = "zsh-fork-brokered-github-credential";
|
|
let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CUSTOM_HOST" "$CUSTOM_API_KEY" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}" "$AUTH_HEADER""#;
|
|
let command = format!(
|
|
"APP_SETTING=production; EXCLUDED_SETTING=denied; [ \"$(/usr/bin/printenv APP_SETTING)\" = production ] || exit 1; if /usr/bin/printenv EXCLUDED_SETTING >/dev/null; then exit 1; fi; {command}"
|
|
);
|
|
let command = if request_extra_permissions {
|
|
let script = format!("{command}\nprintf escaped 2>/dev/null > {outside_path_arg} || true");
|
|
format!("/bin/sh -c {}", shlex::try_quote(&script)?)
|
|
} else {
|
|
command.to_string()
|
|
};
|
|
let snapshot_dir_arg = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?;
|
|
let command = format!("{command}; /bin/cat {snapshot_dir_arg}/*.sh > captured-snapshot");
|
|
let mut arguments = if direct {
|
|
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
|
|
return Ok(());
|
|
};
|
|
json!({
|
|
"cmd": command,
|
|
"shell": startup_shell.map_or_else(
|
|
|| zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(),
|
|
|path| path.display().to_string(),
|
|
),
|
|
"workdir": workdir,
|
|
"login": login,
|
|
"yield_time_ms": 10_000,
|
|
})
|
|
} else {
|
|
json!({
|
|
"cmd": command,
|
|
"workdir": workdir,
|
|
"login": login,
|
|
"yield_time_ms": 10_000,
|
|
})
|
|
};
|
|
if request_extra_permissions {
|
|
let approved_dir = test.home.path().join("approved-command");
|
|
fs::create_dir(&approved_dir)?;
|
|
arguments["sandbox_permissions"] = json!(SandboxPermissions::WithAdditionalPermissions);
|
|
arguments["additional_permissions"] = json!({"file_system": {"write": [approved_dir]}});
|
|
}
|
|
let event = ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?);
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-zsh-fork-broker-1"),
|
|
event,
|
|
ev_completed("resp-zsh-fork-broker-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("msg-zsh-fork-broker", "done"),
|
|
ev_completed("resp-zsh-fork-broker-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"show the brokered GitHub environment",
|
|
approval_policy,
|
|
)
|
|
.await?;
|
|
if request_extra_permissions {
|
|
let mut approvals = 0;
|
|
loop {
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
let EventMsg::ExecApprovalRequest(approval) = event else {
|
|
break;
|
|
};
|
|
approvals += 1;
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
}
|
|
assert!(approvals > 0, "additional permissions must be approved");
|
|
} else {
|
|
wait_for_completion_without_approval(&test).await;
|
|
}
|
|
|
|
let output = responses.requests()[1].function_call_output(call_id);
|
|
if shell_mode == "zsh_fork" {
|
|
let output = output.to_string();
|
|
assert!(output.contains("credential brokerage does not yet support shell_zsh_fork"));
|
|
assert!(!workdir.join("captured-snapshot").exists());
|
|
assert!(!output.contains(REAL_GITHUB_TOKEN));
|
|
return Ok(());
|
|
}
|
|
let result = parse_result(&output);
|
|
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
|
|
assert!(!result.stdout.contains(REAL_GITHUB_TOKEN));
|
|
assert!(!result.stdout.contains(REAL_CUSTOM_TOKEN));
|
|
let values = result.stdout.lines().collect::<Vec<_>>();
|
|
assert_eq!(values.len(), 9, "unexpected command output: {result:?}");
|
|
assert_eq!(values[0], GH_HOST);
|
|
assert_ne!(values[1], REAL_GITHUB_TOKEN);
|
|
if global_startup {
|
|
assert_eq!(values[1], "");
|
|
assert_eq!(values[3], "");
|
|
assert_eq!(
|
|
values[8],
|
|
if shell_mode == "startup_override" {
|
|
"enterprise-header"
|
|
} else {
|
|
""
|
|
}
|
|
);
|
|
} else {
|
|
assert!(values[1].starts_with("ghp_"));
|
|
assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len());
|
|
assert!(values[3].starts_with("custom_"));
|
|
assert_eq!(values[3].len(), REAL_CUSTOM_TOKEN.len());
|
|
assert_eq!(values[8], format!("Bearer {}", values[1]));
|
|
}
|
|
assert_eq!(
|
|
values[2],
|
|
if request_extra_permissions {
|
|
""
|
|
} else {
|
|
CUSTOM_HOST
|
|
}
|
|
);
|
|
assert_ne!(values[3], REAL_CUSTOM_TOKEN);
|
|
assert_eq!(values[4], "1");
|
|
assert_eq!(PathBuf::from(values[5]), fs::canonicalize(&workdir)?);
|
|
assert_eq!(PathBuf::from(values[6]), fs::canonicalize(&workdir)?);
|
|
assert_eq!(values[7], if login { "ready" } else { "unset" });
|
|
assert!(
|
|
!outside_path.exists(),
|
|
"shell snapshot startup wrote outside the command sandbox"
|
|
);
|
|
|
|
let snapshot = fs::read_to_string(workdir.join("captured-snapshot"))?;
|
|
assert!(snapshot.contains("# Snapshot file"));
|
|
assert!(
|
|
!snapshot.contains(REAL_GITHUB_TOKEN),
|
|
"shell snapshot persisted a real GitHub credential"
|
|
);
|
|
assert!(
|
|
!snapshot.contains(REAL_CUSTOM_TOKEN),
|
|
"shell snapshot persisted a real configured credential"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case("escalated")]
|
|
#[test_case("disabled")]
|
|
#[test_case("failed")]
|
|
#[test_case("heredoc")]
|
|
#[test_case("enabled")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn brokered_shell_snapshot_fallback(mode: &'static str) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
const REAL_GITHUB_TOKEN: &str =
|
|
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
|
|
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
|
|
return Ok(());
|
|
};
|
|
let startup_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let startup_path = startup_dir.path().join("startup.sh");
|
|
let observed_path = startup_dir.path().join("startup-credentials");
|
|
let observed_path_arg = shlex::try_join([observed_path.to_string_lossy().as_ref()])?;
|
|
fs::write(
|
|
&startup_path,
|
|
if mode == "escalated" {
|
|
format!("printf '%s\\n' \"${{GH_TOKEN-}}\" >> {observed_path_arg}\n")
|
|
} else if mode == "heredoc" {
|
|
"brokered_heredoc() {\ncat <<EOF\n# exports 1\n\
|
|
export LEGACY_SETTING=production\nAuthorization: ${GH_TOKEN}\n\
|
|
EOF\n}\nexport -f brokered_heredoc\n"
|
|
.to_string()
|
|
} else {
|
|
"shopt -s extglob expand_aliases\nexport CORP_REGION=west\ncorp_auth() { case \"$CORP_REGION\" in @(west|east)) printf '%s\\n' \"$CORP_REGION\" \"$GH_TOKEN\" ;; esac; }\nalias corp_login=corp_auth\n".to_string()
|
|
},
|
|
)?;
|
|
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
format!(
|
|
r#"default_permissions = "brokered"
|
|
features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }}
|
|
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
|
|
|
|
[shell_environment_policy.set]
|
|
BASH_ENV = "{}"
|
|
GH_TOKEN = "{REAL_GITHUB_TOKEN}"
|
|
"#,
|
|
startup_path.display()
|
|
),
|
|
)?;
|
|
if mode == "failed" {
|
|
fs::write(home.path().join("shell_snapshots"), "not a directory")?;
|
|
}
|
|
if mode == "escalated" {
|
|
let rules_dir = home.path().join("rules");
|
|
fs::create_dir_all(&rules_dir)?;
|
|
fs::write(
|
|
rules_dir.join("default.rules"),
|
|
r#"prefix_rule(pattern=["printenv", "GH_TOKEN"], decision="allow")"#,
|
|
)?;
|
|
}
|
|
|
|
let approval_policy = if mode == "escalated" {
|
|
AskForApproval::OnRequest
|
|
} else {
|
|
AskForApproval::Never
|
|
};
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex()
|
|
.with_home(home)
|
|
.with_user_shell(bash)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
if mode == "disabled" {
|
|
config.features.disable(Feature::ShellSnapshot)
|
|
} else {
|
|
config.features.enable(Feature::ShellSnapshot)
|
|
}
|
|
.expect("test config should allow ShellSnapshot override");
|
|
config
|
|
.features
|
|
.disable(Feature::ShellZshFork)
|
|
.expect("test config should allow direct shell execution");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let call_id = "escalated-brokered-shell-startup";
|
|
let command = if mode == "heredoc" {
|
|
let snapshot_dir = test.home.path().join("shell_snapshots");
|
|
let snapshot_dir = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?;
|
|
format!(
|
|
"cat {snapshot_dir}/*.sh > captured-snapshot && brokered_heredoc && printf '%s\\n' \"$GH_TOKEN\""
|
|
)
|
|
} else if mode == "enabled" {
|
|
"corp_login".to_string()
|
|
} else {
|
|
"printenv GH_TOKEN".to_string()
|
|
};
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
if mode == "escalated" {
|
|
SandboxPermissions::RequireEscalated
|
|
} else {
|
|
SandboxPermissions::UseDefault
|
|
},
|
|
)?;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-escalated-broker-startup-1"),
|
|
event,
|
|
ev_completed("resp-escalated-broker-startup-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("msg-escalated-broker-startup", "done"),
|
|
ev_completed("resp-escalated-broker-startup-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"run the allowlisted command with escalated permissions",
|
|
approval_policy,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let output = responses.requests()[1].function_call_output(call_id);
|
|
if matches!(mode, "disabled" | "failed") {
|
|
let output = output.to_string();
|
|
assert!(
|
|
output.contains("credential brokerage could not create a protected shell snapshot")
|
|
);
|
|
assert!(!output.contains(REAL_GITHUB_TOKEN));
|
|
if mode == "failed" {
|
|
fs::remove_file(test.home.path().join("shell_snapshots"))?;
|
|
let mut first_snapshot_paths = None;
|
|
for call_id in ["brokered-startup-retry", "brokered-startup-reuse"] {
|
|
let event = shell_event(
|
|
call_id,
|
|
"printenv GH_TOKEN",
|
|
/*timeout_ms*/ 5_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
let retry = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created(call_id),
|
|
event,
|
|
ev_completed(call_id),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-brokered-startup-done"),
|
|
ev_assistant_message("msg-brokered-startup-done", "done"),
|
|
ev_completed("resp-brokered-startup-done"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"run the command after repairing snapshot storage",
|
|
approval_policy,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_approval(&test).await;
|
|
let result = parse_result(&retry.requests()[1].function_call_output(call_id));
|
|
assert_eq!(
|
|
result.exit_code,
|
|
Some(0),
|
|
"snapshot retry failed: {result:?}"
|
|
);
|
|
assert!(result.stdout.starts_with("ghp_"));
|
|
assert!(!result.stdout.contains(REAL_GITHUB_TOKEN));
|
|
let mut snapshot_paths = fs::read_dir(test.home.path().join("shell_snapshots"))?
|
|
.flatten()
|
|
.map(|entry| entry.path())
|
|
.filter(|path| path.extension().is_some_and(|extension| extension == "sh"))
|
|
.collect::<Vec<_>>();
|
|
snapshot_paths.sort();
|
|
assert_eq!(snapshot_paths.len(), 1);
|
|
if let Some(first_snapshot_paths) = &first_snapshot_paths {
|
|
assert_eq!(&snapshot_paths, first_snapshot_paths);
|
|
} else {
|
|
first_snapshot_paths = Some(snapshot_paths);
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
let result = parse_result(&output);
|
|
if mode == "heredoc" {
|
|
assert_eq!(
|
|
result.exit_code,
|
|
Some(0),
|
|
"heredoc replay failed: {result:?}"
|
|
);
|
|
assert!(!output.to_string().contains(REAL_GITHUB_TOKEN));
|
|
let dummy = result
|
|
.stdout
|
|
.lines()
|
|
.last()
|
|
.context("live dummy credential")?;
|
|
assert!(dummy.starts_with("ghp_"));
|
|
assert_eq!(
|
|
result.stdout,
|
|
format!(
|
|
"# exports 1\nexport LEGACY_SETTING=production\nAuthorization: {dummy}\n{dummy}\n"
|
|
)
|
|
);
|
|
let snapshot = fs::read_to_string(test.cwd.path().join("captured-snapshot"))?;
|
|
assert!(!snapshot.contains(REAL_GITHUB_TOKEN));
|
|
assert!(snapshot.contains("brokered_heredoc"));
|
|
assert!(snapshot.contains("Authorization: ${GH_TOKEN}"));
|
|
return Ok(());
|
|
}
|
|
if mode == "enabled" {
|
|
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
|
|
let values = result.stdout.lines().collect::<Vec<_>>();
|
|
assert_eq!(values.len(), 2);
|
|
assert_eq!(values[0], "west");
|
|
assert!(values[1].starts_with("ghp_"));
|
|
assert_ne!(values[1], REAL_GITHUB_TOKEN);
|
|
return Ok(());
|
|
}
|
|
assert_eq!(
|
|
result,
|
|
CommandResult {
|
|
exit_code: Some(0),
|
|
stdout: format!("{REAL_GITHUB_TOKEN}\n"),
|
|
},
|
|
"fail-open command did not receive the ordinary real-credential environment"
|
|
);
|
|
if mode == "escalated" {
|
|
let observations = fs::read_to_string(&observed_path)?;
|
|
assert!(!observations.is_empty(), "startup file did not run");
|
|
assert!(
|
|
!observations.lines().any(|value| value != REAL_GITHUB_TOKEN),
|
|
"escalated startup observed a broker dummy"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case(true; "credential_startup")]
|
|
#[test_case(false; "ordinary_application_env")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn brokered_posix_startup_preserves_application_env(credential_startup: bool) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
const REAL_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
let Some(shell) = codex_core::shell::get_shell(codex_core::shell::ShellType::Sh) else {
|
|
return Ok(());
|
|
};
|
|
let home = Arc::new(TempDir::new()?);
|
|
let startup = home.path().join("startup.sh");
|
|
let credential_export = format!("export GH_TOKEN='{REAL_TOKEN}'\n");
|
|
fs::write(
|
|
&startup,
|
|
format!(
|
|
"export CORP_REGION=west\n{}",
|
|
if credential_startup {
|
|
credential_export.as_str()
|
|
} else {
|
|
""
|
|
}
|
|
),
|
|
)?;
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
format!(
|
|
r#"default_permissions = "brokered"
|
|
features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }}
|
|
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
|
|
[shell_environment_policy.set]
|
|
ENV = "{}"
|
|
"#,
|
|
startup.display()
|
|
),
|
|
)?;
|
|
let shell_path = shell.derive_exec_args("", /*use_login_shell*/ false)[0].clone();
|
|
let server = start_mock_server().await;
|
|
let test = test_codex()
|
|
.with_home(home)
|
|
.with_user_shell(shell)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
|
config.permissions.allow_login_shell = true;
|
|
config.features.enable(Feature::ShellSnapshot).unwrap();
|
|
config.features.disable(Feature::ShellZshFork).unwrap();
|
|
if !credential_startup {
|
|
config
|
|
.permissions
|
|
.shell_environment_policy
|
|
.r#set
|
|
.insert("GH_TOKEN".to_string(), REAL_TOKEN.to_string());
|
|
}
|
|
})
|
|
.build(&server)
|
|
.await?;
|
|
let script = r#"printf '%s\n%s\n%s\n%s\n' "$GH_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "${ENV-unset}" "$CORP_REGION""#;
|
|
let command = format!(
|
|
"{} 2>/dev/null",
|
|
shlex::try_join([shell_path.as_str(), "-i", "-c", script])?
|
|
);
|
|
let call_id = "brokered-posix-startup";
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("posix-startup-1"),
|
|
shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?,
|
|
ev_completed("posix-startup-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("posix-startup-done", "done"),
|
|
ev_completed("posix-startup-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"show the POSIX startup environment",
|
|
AskForApproval::Never,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_approval(&test).await;
|
|
let result = parse_result(&responses.requests()[1].function_call_output(call_id));
|
|
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
|
|
assert!(!result.stdout.contains(REAL_TOKEN));
|
|
let values = result.stdout.lines().collect::<Vec<_>>();
|
|
assert_eq!(values.len(), 4, "unexpected command output: {result:?}");
|
|
assert!(values[0].starts_with("ghp_"));
|
|
assert_eq!(
|
|
&values[1..],
|
|
&[
|
|
"1",
|
|
if credential_startup {
|
|
"unset"
|
|
} else {
|
|
startup.to_str().unwrap()
|
|
},
|
|
"west"
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[cfg(unix)]
|
|
async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let Some(runtime) = zsh_fork_runtime("zsh-fork env zsh nested escalation test")? else {
|
|
return Ok(());
|
|
};
|
|
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let permission_profile = restrictive_workspace_write_profile();
|
|
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let outside_path = outside_dir.path().join("zsh-fork-env-zsh-escalated.txt");
|
|
let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
|
|
let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string();
|
|
|
|
let server = start_mock_server().await;
|
|
let outside_path_for_hook = outside_path.clone();
|
|
let test = build_zsh_fork_test(
|
|
&server,
|
|
runtime,
|
|
approval_policy,
|
|
permission_profile.clone(),
|
|
move |home| {
|
|
let _ = fs::remove_file(&outside_path_for_hook);
|
|
let rules_dir = home.join("rules");
|
|
fs::create_dir_all(&rules_dir).unwrap();
|
|
fs::write(rules_dir.join("default.rules"), &rules).unwrap();
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let script_path = test.cwd.path().join("runs-under-env-zsh");
|
|
fs::write(
|
|
&script_path,
|
|
format!(
|
|
"#!/usr/bin/env zsh\ntouch {outside_path_arg}\nprint -r -- nested-env-zsh-complete\n"
|
|
),
|
|
)?;
|
|
let mut script_permissions = fs::metadata(&script_path)?.permissions();
|
|
script_permissions.set_mode(0o755);
|
|
fs::set_permissions(&script_path, script_permissions)?;
|
|
|
|
let script_literal = serde_json::to_string(script_path.to_string_lossy().as_ref())?;
|
|
let python_script = format!(
|
|
"import subprocess; subprocess.run([{script_literal}], check=True, close_fds=False)"
|
|
);
|
|
let command = shlex::try_join(["python3", "-c", python_script.as_str()])?;
|
|
|
|
let call_id = "zsh-fork-env-zsh-nested-escalation";
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-zsh-fork-env-zsh-1"),
|
|
event,
|
|
ev_completed("resp-zsh-fork-env-zsh-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-zsh-fork-env-zsh-1", "done"),
|
|
ev_completed("resp-zsh-fork-env-zsh-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let session_model = test.session_configured.model.clone();
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(permission_profile, test.cwd.path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run nested env zsh script through python".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: session_model,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let approval_event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
Duration::from_secs(10),
|
|
)
|
|
.await;
|
|
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
|
|
panic!("expected nested zsh script to request approval before completion");
|
|
};
|
|
assert!(
|
|
approval.command.iter().any(|arg| arg.ends_with("/touch"))
|
|
&& approval
|
|
.command
|
|
.iter()
|
|
.any(|arg| arg == outside_path.to_string_lossy().as_ref()),
|
|
"expected approval for nested touch command, got: {:?}",
|
|
approval.command
|
|
);
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
|
|
wait_for_completion(&test).await;
|
|
|
|
let result = parse_result(&results.single_request().function_call_output(call_id));
|
|
assert_eq!(
|
|
result.exit_code.unwrap_or(0),
|
|
0,
|
|
"nested env zsh script should complete successfully: {}",
|
|
result.stdout
|
|
);
|
|
assert!(
|
|
result.stdout.contains("nested-env-zsh-complete"),
|
|
"nested script did not report completion: {}",
|
|
result.stdout
|
|
);
|
|
assert!(
|
|
outside_path.exists(),
|
|
"approved nested touch should create the out-of-workspace file"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[cfg(unix)]
|
|
async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let Some(runtime) = zsh_fork_runtime("zsh-fork prefix rule unsandboxed test")? else {
|
|
return Ok(());
|
|
};
|
|
|
|
let approval_policy = AskForApproval::Never;
|
|
let permission_profile = restrictive_workspace_write_profile();
|
|
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let outside_path = outside_dir
|
|
.path()
|
|
.join("zsh-fork-prefix-rule-unsandboxed.txt");
|
|
let command = format!("touch {outside_path:?}");
|
|
let rules = r#"prefix_rule(pattern=["touch"], decision="allow")"#.to_string();
|
|
|
|
let server = start_mock_server().await;
|
|
let outside_path_for_hook = outside_path.clone();
|
|
let test = build_zsh_fork_test(
|
|
&server,
|
|
runtime,
|
|
approval_policy,
|
|
permission_profile.clone(),
|
|
move |home| {
|
|
let _ = fs::remove_file(&outside_path_for_hook);
|
|
let rules_dir = home.join("rules");
|
|
fs::create_dir_all(&rules_dir).unwrap();
|
|
fs::write(rules_dir.join("default.rules"), &rules).unwrap();
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let call_id = "zsh-fork-prefix-rule-unsandboxed";
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 1_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-zsh-fork-prefix-1"),
|
|
event,
|
|
ev_completed("resp-zsh-fork-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-zsh-fork-prefix-1", "done"),
|
|
ev_completed("resp-zsh-fork-prefix-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let session_model = test.session_configured.model.clone();
|
|
let (sandbox_policy, permission_profile) =
|
|
turn_permission_fields(permission_profile, test.cwd.path());
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run allowed touch under zsh fork".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
permission_profile,
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: session_model,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let result = parse_result(&results.single_request().function_call_output(call_id));
|
|
assert_eq!(result.exit_code.unwrap_or(0), 0);
|
|
assert!(
|
|
outside_path.exists(),
|
|
"expected matched prefix_rule to rerun touch unsandboxed; output: {}",
|
|
result.stdout
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Verifies that an allowlisted script retains the originating named profile
|
|
/// when its shell tool call requests escalated, unsandboxed execution.
|
|
///
|
|
/// Tool owners use this pattern when a trusted wrapper must run outside the
|
|
/// current sandbox, but then needs to launch child commands back inside the
|
|
/// same sandbox with `codex sandbox -P`. The nested invocation must also pass
|
|
/// `--include-managed-config` so it continues to honor enterprise requirements.
|
|
/// The test proves both halves of that contract: the wrapper writes outside the
|
|
/// `:workspace` sandbox, while its inherited profile name remains `:workspace`.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[cfg(unix)]
|
|
async fn allowed_escalated_exec_command_inherits_active_permission_profile() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
WORKSPACE_PERMISSION_PROFILE_CONFIG,
|
|
)?;
|
|
|
|
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let script_path = script_dir.path().join("print-permission-profile.sh");
|
|
let outside_path = script_dir.path().join("unsandboxed-marker");
|
|
fs::write(
|
|
&script_path,
|
|
format!(
|
|
r#"#!/bin/sh
|
|
# Print the inherited profile so the test can verify that it reached this script.
|
|
printenv CODEX_PERMISSION_PROFILE
|
|
touch {outside_path:?}
|
|
"#
|
|
),
|
|
)?;
|
|
|
|
let rules_dir = home.path().join("rules");
|
|
fs::create_dir_all(&rules_dir)?;
|
|
let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?;
|
|
fs::write(
|
|
rules_dir.join("default.rules"),
|
|
format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#),
|
|
)?;
|
|
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let mut builder = test_codex().with_home(home).with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
|
|
assert_active_workspace_permission_profile(&test);
|
|
|
|
let call_id = "allowed-escalated-shell-inherits-permission-profile";
|
|
let command = format!("/bin/sh {script_path:?}");
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 5_000,
|
|
SandboxPermissions::RequireEscalated,
|
|
)?;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-escalated-profile-1"),
|
|
event,
|
|
ev_completed("resp-escalated-profile-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-escalated-profile-1", "done"),
|
|
ev_completed("resp-escalated-profile-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"run the allowed script with escalated permissions",
|
|
approval_policy,
|
|
)
|
|
.await?;
|
|
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let result = parse_result(&results.single_request().function_call_output(call_id));
|
|
assert_eq!(
|
|
result,
|
|
CommandResult {
|
|
exit_code: Some(0),
|
|
stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"),
|
|
},
|
|
"the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command"
|
|
);
|
|
assert!(
|
|
outside_path.exists(),
|
|
"allowed escalated script should run outside the :workspace sandbox"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Verifies that zsh-fork applies an inner script's allow rule even when the
|
|
/// model invokes an outer wrapper, and that the escalated script retains the
|
|
/// named profile needed to reconstruct the original sandbox remotely without
|
|
/// dropping managed enterprise requirements. The script treats the inherited
|
|
/// environment value as untrusted and accepts only explicitly allowlisted
|
|
/// profile names before passing one to `codex sandbox -P`.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[cfg(unix)]
|
|
async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else {
|
|
return Ok(());
|
|
};
|
|
|
|
const HOST: &str = "builder.example.com";
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
|
let wrapper_path = script_dir.path().join("remote-bash");
|
|
let remote_bash_path = script_dir.path().join("remote_bash.py");
|
|
let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker");
|
|
let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?;
|
|
fs::write(
|
|
&remote_bash_path,
|
|
format!(
|
|
r#"#!/usr/bin/env python3
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import sys
|
|
|
|
ALLOWED_HOSTS = ("builder.example.com",)
|
|
ALLOWED_PROFILES = (":workspace",)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Print an ssh command that recreates the current Codex sandbox remotely."
|
|
)
|
|
parser.add_argument("--host", required=True)
|
|
try:
|
|
separator = sys.argv.index("--")
|
|
except ValueError:
|
|
parser.error("the remote command must follow --")
|
|
args = parser.parse_args(sys.argv[1:separator])
|
|
args.command = sys.argv[separator + 1:]
|
|
if not args.command:
|
|
parser.error("the remote command must not be empty")
|
|
if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS:
|
|
parser.error("host is not allowlisted")
|
|
return args
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
profile_name = os.environ.get("CODEX_PERMISSION_PROFILE")
|
|
if not profile_name:
|
|
raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
|
|
if profile_name not in ALLOWED_PROFILES:
|
|
raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")
|
|
|
|
shell_command = shlex.join(args.command)
|
|
sandbox_command = shlex.join(
|
|
[
|
|
"codex",
|
|
"sandbox",
|
|
"-P",
|
|
profile_name,
|
|
"--include-managed-config",
|
|
"--",
|
|
"bash",
|
|
"-lc",
|
|
shell_command,
|
|
]
|
|
)
|
|
print(shlex.join(["ssh", args.host, sandbox_command]))
|
|
|
|
# Test-only proof that this inner script was allowed to run unsandboxed.
|
|
Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
"#
|
|
),
|
|
)?;
|
|
let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?;
|
|
fs::write(
|
|
&wrapper_path,
|
|
format!(
|
|
r#"#!/usr/bin/env zsh
|
|
exec {remote_bash_exec} "$@"
|
|
"#
|
|
),
|
|
)?;
|
|
for path in [&wrapper_path, &remote_bash_path] {
|
|
let mut permissions = fs::metadata(path)?.permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(path, permissions)?;
|
|
}
|
|
|
|
let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?;
|
|
let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#);
|
|
let server = start_mock_server().await;
|
|
let mut builder =
|
|
zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| {
|
|
fs::write(
|
|
home.join("config.toml"),
|
|
WORKSPACE_PERMISSION_PROFILE_CONFIG,
|
|
)
|
|
.expect("write config");
|
|
let rules_dir = home.join("rules");
|
|
fs::create_dir_all(&rules_dir).expect("create rules dir");
|
|
fs::write(rules_dir.join("default.rules"), rules).expect("write rules");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
|
|
assert_active_workspace_permission_profile(&test);
|
|
|
|
let command = shlex::try_join([
|
|
wrapper_path.to_string_lossy().as_ref(),
|
|
"--host",
|
|
HOST,
|
|
"--",
|
|
"printf",
|
|
"%s",
|
|
"hello world",
|
|
])?;
|
|
let call_id = "zsh-fork-remote-sandbox-wrapper";
|
|
let event = shell_event(
|
|
call_id,
|
|
&command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-zsh-fork-remote-wrapper-1"),
|
|
event,
|
|
ev_completed("resp-zsh-fork-remote-wrapper-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"),
|
|
ev_completed("resp-zsh-fork-remote-wrapper-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn_preserving_active_permission_profile(
|
|
&test,
|
|
"run the remote sandbox wrapper",
|
|
approval_policy,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let result = parse_result(&results.single_request().function_call_output(call_id));
|
|
assert_eq!(
|
|
result.exit_code.unwrap_or(0),
|
|
0,
|
|
"the inner remote_bash.py script should run successfully: {}",
|
|
result.stdout
|
|
);
|
|
let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?;
|
|
assert_eq!(
|
|
ssh_argv.len(),
|
|
3,
|
|
"expected ssh HOST LONG_COMMAND, got: {}",
|
|
result.stdout
|
|
);
|
|
assert_eq!(
|
|
ssh_argv[..2],
|
|
["ssh", HOST],
|
|
"remote_bash.py should target only the allowlisted host"
|
|
);
|
|
let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?;
|
|
assert_eq!(
|
|
sandbox_argv.len(),
|
|
9,
|
|
"expected codex sandbox ... bash -lc CMD"
|
|
);
|
|
assert_eq!(
|
|
sandbox_argv[..8],
|
|
[
|
|
"codex",
|
|
"sandbox",
|
|
"-P",
|
|
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
|
|
"--include-managed-config",
|
|
"--",
|
|
"bash",
|
|
"-lc",
|
|
],
|
|
"remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox"
|
|
);
|
|
let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?;
|
|
assert_eq!(
|
|
command_argv,
|
|
["printf", "%s", "hello world"],
|
|
"remote_bash.py should preserve every argument after --"
|
|
);
|
|
assert!(
|
|
outside_path.exists(),
|
|
"the inner allowlisted script should run outside the :workspace sandbox"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let call_id = "invalid-prefix-rule";
|
|
let command =
|
|
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
|
|
let event = shell_event_with_prefix_rule(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 1_000,
|
|
SandboxPermissions::RequireEscalated,
|
|
Some(vec!["touch".to_string()]),
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-invalid-prefix-1"),
|
|
event,
|
|
ev_completed("resp-invalid-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"invalid-prefix-rule",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let approval = expect_exec_approval(&test, command).await;
|
|
let amendment = approval
|
|
.proposed_execpolicy_amendment
|
|
.expect("should have a proposed execpolicy amendment");
|
|
assert!(amendment.command.contains(&command.to_string()));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let call_id = "invalid-prefix-rule";
|
|
let command =
|
|
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
|
|
let event = shell_event_with_prefix_rule(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 10_000,
|
|
SandboxPermissions::RequireEscalated,
|
|
Some(vec!["touch".to_string()]),
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-invalid-prefix-1"),
|
|
event,
|
|
ev_completed("resp-invalid-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"invalid-prefix-rule",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let approval = expect_exec_approval(&test, command).await;
|
|
let approval_id = approval.effective_approval_id();
|
|
let amendment = approval
|
|
.proposed_execpolicy_amendment
|
|
.expect("should have a proposed execpolicy amendment");
|
|
assert!(amendment.command.contains(&command.to_string()));
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval_id,
|
|
turn_id: None,
|
|
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment: amendment.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
let call_id = "invalid-prefix-rule-again";
|
|
let command =
|
|
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
|
|
let event = shell_event_with_prefix_rule(
|
|
call_id,
|
|
command,
|
|
/*timeout_ms*/ 10_000,
|
|
SandboxPermissions::RequireEscalated,
|
|
Some(vec!["touch".to_string()]),
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-invalid-prefix-1"),
|
|
event,
|
|
ev_completed("resp-invalid-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let second_results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-invalid-prefix-1", "done"),
|
|
ev_completed("resp-invalid-prefix-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"invalid-prefix-rule",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
wait_for_completion_without_approval(&test).await;
|
|
|
|
let second_output = parse_result(
|
|
&second_results
|
|
.single_request()
|
|
.function_call_output(call_id),
|
|
);
|
|
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
|
|
assert!(
|
|
second_output.stdout.is_empty(),
|
|
"unexpected stdout: {}",
|
|
second_output.stdout
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn denying_network_policy_amendment_persists_policy_and_skips_future_network_prompt()
|
|
-> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
r#"default_permissions = "workspace"
|
|
|
|
[permissions.workspace.filesystem]
|
|
":minimal" = "read"
|
|
|
|
[permissions.workspace.network]
|
|
enabled = true
|
|
mode = "limited"
|
|
allow_local_binding = true
|
|
"#,
|
|
)?;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex()
|
|
.with_home(home)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
assert!(
|
|
test.config.managed_network_requirements_enabled(),
|
|
"expected managed network requirements to be enabled"
|
|
);
|
|
assert!(
|
|
test.config.permissions.network.is_some(),
|
|
"expected managed network proxy config to be present"
|
|
);
|
|
test.session_configured
|
|
.network_proxy
|
|
.as_ref()
|
|
.expect("expected runtime managed network proxy addresses");
|
|
|
|
let call_id_first = "allow-network-first";
|
|
// Use urllib without overriding proxy settings so managed-network sessions
|
|
// continue to exercise the env-based proxy routing path under bubblewrap.
|
|
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
|
|
.to_string();
|
|
let first_event = shell_event(
|
|
call_id_first,
|
|
&fetch_command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-allow-network-1"),
|
|
first_event,
|
|
ev_completed("resp-allow-network-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let first_results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-allow-network-1", "done"),
|
|
ev_completed("resp-allow-network-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"allow-network-first",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let approval = loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(std::time::Instant::now())
|
|
.expect("timed out waiting for network approval request");
|
|
let event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
remaining,
|
|
)
|
|
.await;
|
|
match event {
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
if approval.command.first().map(std::string::String::as_str)
|
|
== Some("network-access")
|
|
{
|
|
break approval;
|
|
}
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
}
|
|
EventMsg::TurnComplete(_) => {
|
|
panic!("expected network approval request before completion");
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
};
|
|
let network_context = approval
|
|
.network_approval_context
|
|
.clone()
|
|
.expect("expected network approval context");
|
|
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
|
|
let expected_network_amendments = vec![
|
|
NetworkPolicyAmendment {
|
|
host: network_context.host.clone(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
},
|
|
NetworkPolicyAmendment {
|
|
host: network_context.host.clone(),
|
|
action: NetworkPolicyRuleAction::Deny,
|
|
},
|
|
];
|
|
assert_eq!(
|
|
approval.proposed_network_policy_amendments,
|
|
Some(expected_network_amendments.clone())
|
|
);
|
|
let deny_network_amendment = expected_network_amendments
|
|
.into_iter()
|
|
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Deny)
|
|
.expect("expected deny network policy amendment");
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::NetworkPolicyAmendment {
|
|
network_policy_amendment: deny_network_amendment.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
let policy_path = test.home.path().join("rules").join("default.rules");
|
|
let policy_contents = fs::read_to_string(&policy_path)?;
|
|
let expected_rule = format!(
|
|
r#"network_rule(host="{}", protocol="{}", decision="deny", justification="Deny {} access to {}")"#,
|
|
deny_network_amendment.host,
|
|
match network_context.protocol {
|
|
NetworkApprovalProtocol::Http => "http",
|
|
NetworkApprovalProtocol::Https => "https_connect",
|
|
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
|
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
|
},
|
|
match network_context.protocol {
|
|
NetworkApprovalProtocol::Http => "http",
|
|
NetworkApprovalProtocol::Https => "https_connect",
|
|
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
|
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
|
},
|
|
deny_network_amendment.host
|
|
);
|
|
assert!(
|
|
policy_contents.contains(&expected_rule),
|
|
"unexpected policy contents: {policy_contents}"
|
|
);
|
|
assert!(first_results.requests().iter().any(|request| {
|
|
request.body_contains_text(&format!(
|
|
"Denied network rule saved in execpolicy (denylist): {}",
|
|
deny_network_amendment.host
|
|
))
|
|
}));
|
|
|
|
let first_output = parse_result(
|
|
&first_results
|
|
.single_request()
|
|
.function_call_output(call_id_first),
|
|
);
|
|
Expectation::CommandFailure {
|
|
output_contains: "",
|
|
}
|
|
.verify(&test, &first_output)?;
|
|
|
|
let call_id_second = "allow-network-second";
|
|
let second_event = shell_event(
|
|
call_id_second,
|
|
&fetch_command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-allow-network-3"),
|
|
second_event,
|
|
ev_completed("resp-allow-network-3"),
|
|
]),
|
|
)
|
|
.await;
|
|
let second_results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-allow-network-2", "done"),
|
|
ev_completed("resp-allow-network-4"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"allow-network-second",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(std::time::Instant::now())
|
|
.expect("timed out waiting for second turn completion");
|
|
let event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
remaining,
|
|
)
|
|
.await;
|
|
match event {
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
if approval.command.first().map(std::string::String::as_str)
|
|
== Some("network-access")
|
|
{
|
|
panic!(
|
|
"unexpected network approval request: {:?}",
|
|
approval.command
|
|
);
|
|
}
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
}
|
|
EventMsg::TurnComplete(_) => break,
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
let second_output = parse_result(
|
|
&second_results
|
|
.single_request()
|
|
.function_call_output(call_id_second),
|
|
);
|
|
Expectation::CommandFailure {
|
|
output_contains: "",
|
|
}
|
|
.verify(&test, &second_output)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn network_approval_retry_keeps_deny_read_sandbox_for_escalated_command() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
r#"default_permissions = "workspace"
|
|
|
|
[permissions.workspace.filesystem]
|
|
":minimal" = "read"
|
|
|
|
[permissions.workspace.network]
|
|
enabled = true
|
|
mode = "limited"
|
|
allow_local_binding = true
|
|
"#,
|
|
)?;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let mut builder = test_codex()
|
|
.with_home(home)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
assert!(
|
|
test.config.managed_network_requirements_enabled(),
|
|
"expected managed network requirements to be enabled"
|
|
);
|
|
assert!(
|
|
test.config.permissions.network.is_some(),
|
|
"expected managed network proxy config to be present"
|
|
);
|
|
test.session_configured
|
|
.network_proxy
|
|
.as_ref()
|
|
.expect("expected runtime managed network proxy addresses");
|
|
|
|
let mut file_system_sandbox_policy =
|
|
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
|
|
&sandbox_policy,
|
|
test.config.cwd.as_path(),
|
|
);
|
|
file_system_sandbox_policy
|
|
.entries
|
|
.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: format!("{}/**/*.env", test.config.cwd.as_path().display()),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
});
|
|
assert!(
|
|
file_system_sandbox_policy.has_denied_read_restrictions(),
|
|
"test must exercise a permission profile with denied reads"
|
|
);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&file_system_sandbox_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
|
|
let call_id = "deny-read-network-retry";
|
|
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
|
|
.to_string();
|
|
let event = shell_event(
|
|
call_id,
|
|
&fetch_command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::RequireEscalated,
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-deny-read-network-1"),
|
|
event,
|
|
ev_completed("resp-deny-read-network-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let results = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-deny-read-network-1", "done"),
|
|
ev_completed("resp-deny-read-network-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let (turn_sandbox_policy, turn_permission_profile) =
|
|
turn_permission_fields(permission_profile, test.config.cwd.as_path());
|
|
let session_model = test.session_configured.model.clone();
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "deny-read network retry".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: Some(turn_sandbox_policy),
|
|
permission_profile: turn_permission_profile,
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: session_model,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let mut command_approval_count = 0;
|
|
let approval = loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(std::time::Instant::now())
|
|
.expect("timed out waiting for network approval request");
|
|
let event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
remaining,
|
|
)
|
|
.await;
|
|
match event {
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
if approval.command.first().map(std::string::String::as_str)
|
|
== Some("network-access")
|
|
{
|
|
break approval;
|
|
}
|
|
command_approval_count += 1;
|
|
assert_eq!(
|
|
command_approval_count, 1,
|
|
"expected only the outer explicit escalation approval"
|
|
);
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
}
|
|
EventMsg::TurnComplete(_) => {
|
|
panic!("expected network approval request before completion");
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
};
|
|
assert_eq!(command_approval_count, 1);
|
|
let network_context = approval
|
|
.network_approval_context
|
|
.clone()
|
|
.expect("expected network approval context");
|
|
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
|
|
let allow_network_amendment = approval
|
|
.proposed_network_policy_amendments
|
|
.clone()
|
|
.expect("expected proposed network policy amendments")
|
|
.into_iter()
|
|
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
|
|
.expect("expected allow network policy amendment");
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::NetworkPolicyAmendment {
|
|
network_policy_amendment: allow_network_amendment,
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
let output = parse_result(&results.single_request().function_call_output(call_id));
|
|
assert!(
|
|
output.exit_code.is_some(),
|
|
"expected the approved retry to report command output"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn network_approval_flow_survives_danger_full_access_session_start() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let home = Arc::new(TempDir::new()?);
|
|
fs::write(
|
|
home.path().join("config.toml"),
|
|
r#"default_permissions = "workspace"
|
|
|
|
[permissions.workspace.filesystem]
|
|
":minimal" = "read"
|
|
|
|
[permissions.workspace.network]
|
|
enabled = true
|
|
mode = "limited"
|
|
allow_local_binding = true
|
|
"#,
|
|
)?;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let mut builder = test_codex()
|
|
.with_home(home)
|
|
.with_cloud_config_bundle(managed_network_requirements_loader())
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
let cwd = config.cwd.clone();
|
|
config
|
|
.permissions
|
|
.set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path())
|
|
.expect("test setup should allow sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
assert!(
|
|
!test.config.managed_network_requirements_enabled(),
|
|
"expected managed network requirements to stay inactive in danger-full-access"
|
|
);
|
|
assert!(
|
|
test.config.permissions.network.is_some(),
|
|
"expected managed network proxy config to be present"
|
|
);
|
|
assert!(
|
|
test.session_configured.network_proxy.is_none(),
|
|
"expected session configured event to hide managed network proxy in danger-full-access"
|
|
);
|
|
|
|
let call_id = "allow-network-after-yolo";
|
|
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
|
|
.to_string();
|
|
let event = shell_event(
|
|
call_id,
|
|
&fetch_command,
|
|
/*timeout_ms*/ 30_000,
|
|
SandboxPermissions::UseDefault,
|
|
)?;
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-network-after-yolo-1"),
|
|
event,
|
|
ev_completed("resp-network-after-yolo-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-network-after-yolo-1", "done"),
|
|
ev_completed("resp-network-after-yolo-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"allow-network-after-yolo",
|
|
approval_policy,
|
|
turn_sandbox_policy,
|
|
)
|
|
.await?;
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let approval = loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(std::time::Instant::now())
|
|
.expect("timed out waiting for network approval request");
|
|
let event = wait_for_event_with_timeout(
|
|
&test.codex,
|
|
|event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
},
|
|
remaining,
|
|
)
|
|
.await;
|
|
match event {
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
if approval.command.first().map(std::string::String::as_str)
|
|
== Some("network-access")
|
|
{
|
|
break approval;
|
|
}
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
}
|
|
EventMsg::TurnComplete(_) => {
|
|
panic!("expected network approval request before completion");
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
};
|
|
|
|
let network_context = approval
|
|
.network_approval_context
|
|
.clone()
|
|
.expect("expected network approval context");
|
|
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// todo(dylan) add ScenarioSpec support for rules
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[cfg(unix)]
|
|
async fn compound_command_with_one_safe_command_still_requires_approval() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::UnlessTrusted;
|
|
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let rules_dir = test.home.path().join("rules");
|
|
fs::create_dir_all(&rules_dir)?;
|
|
fs::write(
|
|
rules_dir.join("default.rules"),
|
|
r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#,
|
|
)?;
|
|
|
|
let call_id = "heredoc-with-chained-prefix";
|
|
let command = "touch ./test.txt && rm ./test.txt";
|
|
let (event, expected_command) = ActionKind::RunCommand { command }
|
|
.prepare(&test, &server, call_id, SandboxPermissions::UseDefault)
|
|
.await?;
|
|
let expected_command =
|
|
expected_command.expect("compound command should produce a shell command");
|
|
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-heredoc-prefix-1"),
|
|
event,
|
|
ev_completed("resp-heredoc-prefix-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let _ = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_assistant_message("msg-heredoc-prefix-1", "done"),
|
|
ev_completed("resp-heredoc-prefix-2"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
submit_turn(
|
|
&test,
|
|
"compound command",
|
|
approval_policy,
|
|
sandbox_policy.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::denied("rejected by user"),
|
|
})
|
|
.await?;
|
|
wait_for_completion(&test).await;
|
|
|
|
Ok(())
|
|
}
|