mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
core: test workspace roots with write permissions
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_features::Feature;
|
||||
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::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::TestTargetOs;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
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;
|
||||
@@ -21,36 +15,22 @@ use core_test_support::responses::ev_response_created;
|
||||
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_wine_exec;
|
||||
use core_test_support::skip_if_target_windows;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_target_os;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const IMAGE_CALL_ID: &str = "workspace-root-image";
|
||||
const PATCH_CALL_ID: &str = "workspace-root-patch";
|
||||
const COMMAND_CALL_ID: &str = "workspace-root-command";
|
||||
const PNG_BASE64: &str =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||
|
||||
fn workspace_roots_read_profile() -> PermissionProfile {
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]),
|
||||
fn workspace_roots_profile() -> PermissionProfile {
|
||||
PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,35 +55,36 @@ fn outside_workspace_path(test: &TestCodex, file_name: &str) -> Result<PathUri>
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn command_arguments(path: &str) -> Result<String> {
|
||||
fn command_arguments(path: &str, contents: &str) -> Result<String> {
|
||||
let (shell, command) = match test_target_os() {
|
||||
TestTargetOs::Linux | TestTargetOs::MacOs => ("bash", format!("cat '{path}'")),
|
||||
TestTargetOs::Windows => ("powershell", format!("Get-Content -Raw '{path}'")),
|
||||
TestTargetOs::Linux | TestTargetOs::MacOs => {
|
||||
("bash", format!("printf %s '{contents}' > '{path}'"))
|
||||
}
|
||||
TestTargetOs::Windows => (
|
||||
"powershell",
|
||||
format!("Set-Content -NoNewline -Path '{path}' -Value '{contents}'"),
|
||||
),
|
||||
};
|
||||
Ok(serde_json::to_string(&json!({
|
||||
"cmd": command,
|
||||
"shell": shell,
|
||||
"login": false,
|
||||
"yield_time_ms": 10_000,
|
||||
}))?)
|
||||
}
|
||||
|
||||
async fn mount_file_and_command_calls(
|
||||
async fn mount_patch_and_command_calls(
|
||||
server: &MockServer,
|
||||
image_path: &str,
|
||||
patch: &str,
|
||||
command_path: &str,
|
||||
command_contents: &str,
|
||||
) -> Result<ResponseMock> {
|
||||
let command_arguments = command_arguments(command_path)?;
|
||||
let command_arguments = command_arguments(command_path, command_contents)?;
|
||||
Ok(mount_sse_sequence(
|
||||
server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
IMAGE_CALL_ID,
|
||||
"view_image",
|
||||
&json!({ "path": image_path }).to_string(),
|
||||
),
|
||||
ev_apply_patch_custom_tool_call(PATCH_CALL_ID, patch),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
@@ -122,10 +103,16 @@ async fn mount_file_and_command_calls(
|
||||
}
|
||||
|
||||
async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> {
|
||||
test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile())
|
||||
test.submit_turn_with_permission_profile(prompt, workspace_roots_profile())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_file(test: &TestCodex, path: &PathUri) -> Result<String> {
|
||||
Ok(String::from_utf8(
|
||||
test.fs().read_file(path, /*sandbox*/ None).await?,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> {
|
||||
for path in paths {
|
||||
test.fs()
|
||||
@@ -143,125 +130,113 @@ async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> {
|
||||
async fn workspace_roots_allow_file_and_command_writes() -> Result<()> {
|
||||
const PATCH_CONTENTS: &str = "workspace root patch access";
|
||||
const COMMAND_CONTENTS: &str = "workspace root command access";
|
||||
|
||||
skip_if_wine_exec!(
|
||||
skip_if_target_windows!(
|
||||
Ok(()),
|
||||
"remote Windows sandboxed process launches are not supported"
|
||||
"sandboxed process launch is not supported by the exec-server Windows backend"
|
||||
);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = workspace_roots_test(&server).await?;
|
||||
let cwd = PathUri::from_abs_path(&test.config.cwd);
|
||||
let image_path = cwd.join("workspace-root.png")?;
|
||||
let text_path = cwd.join("workspace-root.txt")?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&image_path,
|
||||
BASE64_STANDARD.decode(PNG_BASE64)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&text_path,
|
||||
COMMAND_CONTENTS.as_bytes().to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let patch_path = cwd.join("workspace-root-patch.txt")?;
|
||||
let command_path = cwd.join("workspace-root-command.txt")?;
|
||||
let patch = format!(
|
||||
"*** Begin Patch\n*** Add File: workspace-root-patch.txt\n+{PATCH_CONTENTS}\n*** End Patch\n"
|
||||
);
|
||||
|
||||
let response_mock =
|
||||
mount_file_and_command_calls(&server, "workspace-root.png", "workspace-root.txt").await?;
|
||||
submit_workspace_turn(&test, "read files inside the workspace roots").await?;
|
||||
let response_mock = mount_patch_and_command_calls(
|
||||
&server,
|
||||
&patch,
|
||||
"workspace-root-command.txt",
|
||||
COMMAND_CONTENTS,
|
||||
)
|
||||
.await?;
|
||||
submit_workspace_turn(&test, "write files inside the workspace roots").await?;
|
||||
|
||||
let request = response_mock
|
||||
.last_request()
|
||||
.context("model should receive both workspace-root tool results")?;
|
||||
let image_output = request.function_call_output(IMAGE_CALL_ID);
|
||||
let image_url = image_output
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("image_url"))
|
||||
.and_then(Value::as_str)
|
||||
.context("filesystem read should return an image")?;
|
||||
assert!(image_url.starts_with("data:image/png;base64,"));
|
||||
let (_, patch_success) = request
|
||||
.custom_tool_call_output_content_and_success(PATCH_CALL_ID)
|
||||
.context("patch result should be present")?;
|
||||
assert_ne!(patch_success, Some(false));
|
||||
|
||||
let (command_output, success) = request
|
||||
let (_, command_success) = request
|
||||
.function_call_output_content_and_success(COMMAND_CALL_ID)
|
||||
.context("command result should be present")?;
|
||||
assert_ne!(success, Some(false));
|
||||
assert!(
|
||||
command_output
|
||||
.as_deref()
|
||||
.is_some_and(|output| output.contains(COMMAND_CONTENTS)),
|
||||
"command should read the workspace-root file, got {command_output:?}"
|
||||
assert_ne!(command_success, Some(false));
|
||||
assert_eq!(
|
||||
read_file(&test, &patch_path).await?,
|
||||
format!("{PATCH_CONTENTS}\n")
|
||||
);
|
||||
assert_eq!(read_file(&test, &command_path).await?, COMMAND_CONTENTS);
|
||||
|
||||
remove_files(&test, &[&image_path, &text_path]).await
|
||||
remove_files(&test, &[&patch_path, &command_path]).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> {
|
||||
const OUTSIDE_CONTENTS: &str = "outside workspace root";
|
||||
async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result<()> {
|
||||
const PATCH_CONTENTS: &str = "outside workspace root patch";
|
||||
const COMMAND_CONTENTS: &str = "outside workspace root command";
|
||||
|
||||
skip_if_wine_exec!(
|
||||
skip_if_target_windows!(
|
||||
Ok(()),
|
||||
"remote Windows sandboxed process launches are not supported"
|
||||
"sandboxed process launch is not supported by the exec-server Windows backend"
|
||||
);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = workspace_roots_test(&server).await?;
|
||||
let image_path = outside_workspace_path(&test, "outside.png")?;
|
||||
let text_path = outside_workspace_path(&test, "outside.txt")?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&image_path,
|
||||
BASE64_STANDARD.decode(PNG_BASE64)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&text_path,
|
||||
OUTSIDE_CONTENTS.as_bytes().to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let image_path_display = image_path.inferred_native_path_string();
|
||||
let text_path_display = text_path.inferred_native_path_string();
|
||||
let patch_path = outside_workspace_path(&test, "outside-patch.txt")?;
|
||||
let command_path = outside_workspace_path(&test, "outside-command.txt")?;
|
||||
let patch_path_display = patch_path.inferred_native_path_string();
|
||||
let command_path_display = command_path.inferred_native_path_string();
|
||||
let patch = format!(
|
||||
"*** Begin Patch\n*** Add File: {patch_path_display}\n+{PATCH_CONTENTS}\n*** End Patch\n"
|
||||
);
|
||||
|
||||
let response_mock =
|
||||
mount_file_and_command_calls(&server, &image_path_display, &text_path_display).await?;
|
||||
submit_workspace_turn(&test, "try to read files outside the workspace roots").await?;
|
||||
mount_patch_and_command_calls(&server, &patch, &command_path_display, COMMAND_CONTENTS)
|
||||
.await?;
|
||||
submit_workspace_turn(&test, "try to write files outside the workspace roots").await?;
|
||||
|
||||
let request = response_mock
|
||||
.last_request()
|
||||
.context("model should receive both denied tool results")?;
|
||||
let (file_output, _) = request
|
||||
.function_call_output_content_and_success(IMAGE_CALL_ID)
|
||||
.context("denied file-read result should be present")?;
|
||||
assert!(file_output.is_some_and(|output| {
|
||||
output.starts_with(&format!(
|
||||
"unable to locate image at `{image_path_display}`:"
|
||||
)) || output.starts_with(&format!("unable to read image at `{image_path_display}`:"))
|
||||
}));
|
||||
let (patch_output, patch_success) = request
|
||||
.custom_tool_call_output_content_and_success(PATCH_CALL_ID)
|
||||
.context("denied patch result should be present")?;
|
||||
assert_ne!(patch_success, Some(true));
|
||||
assert!(
|
||||
patch_output
|
||||
.as_deref()
|
||||
.is_some_and(|output| output.contains("outside of the project")),
|
||||
"patch should be denied outside the workspace roots, got {patch_output:?}"
|
||||
);
|
||||
|
||||
let (command_output, _) = request
|
||||
.function_call_output_content_and_success(COMMAND_CALL_ID)
|
||||
.context("denied command result should be present")?;
|
||||
let command_output = command_output.context("denied command output should be present")?;
|
||||
assert!(
|
||||
command_output.contains(&text_path_display),
|
||||
"denied command output should identify {text_path_display}, got {command_output:?}"
|
||||
command_output.contains(&command_path_display),
|
||||
"denied command output should identify {command_path_display}, got {command_output:?}"
|
||||
);
|
||||
assert!(
|
||||
command_output.contains("Permission denied")
|
||||
|| command_output.contains("Operation not permitted")
|
||||
|| command_output.contains("No such file or directory")
|
||||
test.fs()
|
||||
.read_file(&patch_path, /*sandbox*/ None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
test.fs()
|
||||
.read_file(&command_path, /*sandbox*/ None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!command_output.contains(OUTSIDE_CONTENTS));
|
||||
|
||||
remove_files(&test, &[&image_path, &text_path]).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user