mirror of
https://github.com/openai/codex.git
synced 2026-05-02 02:17:22 +00:00
## Why `ReadOnlyAccess` was a transitional legacy shape on `SandboxPolicy`: `FullAccess` meant the historical read-only/workspace-write modes could read the full filesystem, while `Restricted` tried to carry partial readable roots. The partial-read model now belongs in `FileSystemSandboxPolicy` and `PermissionProfile`, so keeping it on `SandboxPolicy` makes every legacy projection reintroduce lossy read-root bookkeeping and creates unnecessary noise in the rest of the permissions migration. This PR makes the legacy policy model narrower and explicit: `SandboxPolicy::ReadOnly` and `SandboxPolicy::WorkspaceWrite` represent the old full-read sandbox modes only. Split readable roots, deny-read globs, and platform-default/minimal read behavior stay in the runtime permissions model. ## What changed - Removes `ReadOnlyAccess` from `codex_protocol::protocol::SandboxPolicy`, including the generated `access` and `readOnlyAccess` API fields. - Updates legacy policy/profile conversions so restricted filesystem reads are represented only by `FileSystemSandboxPolicy` / `PermissionProfile` entries. - Keeps app-server v2 compatible with legacy `fullAccess` read-access payloads by accepting and ignoring that no-op shape, while rejecting legacy `restricted` read-access payloads instead of silently widening them to full-read legacy policies. - Carries Windows sandbox platform-default read behavior with an explicit override flag instead of depending on `ReadOnlyAccess::Restricted`. - Refreshes generated app-server schema/types and updates tests/docs for the simplified legacy policy shape. ## Verification - `cargo check -p codex-app-server-protocol --tests` - `cargo check -p codex-windows-sandbox --tests` - `cargo test -p codex-app-server-protocol sandbox_policy_` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19449). * #19395 * #19394 * #19393 * #19392 * #19391 * __->__ #19449
355 lines
12 KiB
Rust
355 lines
12 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_exec_server::CopyOptions;
|
|
use codex_exec_server::CreateDirectoryOptions;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::RemoveOptions;
|
|
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_utils_absolute_path::AbsolutePathBuf;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::get_remote_test_env;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::test_codex::test_env;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::time::SystemTime;
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let file_path_abs = remote_test_file_path().abs();
|
|
let payload = b"remote-test-env-ok".to_vec();
|
|
|
|
file_system
|
|
.write_file(&file_path_abs, payload.clone(), /*sandbox*/ None)
|
|
.await?;
|
|
let actual = file_system
|
|
.read_file(&file_path_abs, /*sandbox*/ None)
|
|
.await?;
|
|
assert_eq!(actual, payload);
|
|
|
|
file_system
|
|
.remove(
|
|
&file_path_abs,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
|
|
match AbsolutePathBuf::try_from(path) {
|
|
Ok(path) => path,
|
|
Err(error) => panic!("path should be absolute: {error}"),
|
|
}
|
|
}
|
|
|
|
fn read_only_sandbox(readable_root: PathBuf) -> FileSystemSandboxContext {
|
|
let readable_root = absolute_path(readable_root);
|
|
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: readable_root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
))
|
|
}
|
|
|
|
fn workspace_write_sandbox(writable_root: PathBuf) -> FileSystemSandboxContext {
|
|
let writable_root = absolute_path(writable_root);
|
|
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: writable_root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
))
|
|
}
|
|
|
|
fn assert_normalized_path_rejected(error: &std::io::Error) {
|
|
match error.kind() {
|
|
std::io::ErrorKind::NotFound => assert!(
|
|
error.to_string().contains("No such file or directory"),
|
|
"unexpected not-found message: {error}",
|
|
),
|
|
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => {
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("is not permitted")
|
|
|| message.contains("Operation not permitted")
|
|
|| message.contains("Permission denied"),
|
|
"unexpected rejection message: {message}",
|
|
);
|
|
}
|
|
other => panic!("unexpected normalized-path error kind: {other:?}: {error:?}"),
|
|
}
|
|
}
|
|
|
|
fn remote_exec(script: &str) -> Result<()> {
|
|
let remote_env = get_remote_test_env().context("remote env should be configured")?;
|
|
let output = Command::new("docker")
|
|
.args(["exec", &remote_env.container_name, "sh", "-lc", script])
|
|
.output()?;
|
|
assert!(
|
|
output.status.success(),
|
|
"remote exec failed: stdout={} stderr={}",
|
|
String::from_utf8_lossy(&output.stdout).trim(),
|
|
String::from_utf8_lossy(&output.stderr).trim(),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let allowed_dir = PathBuf::from(format!("/tmp/codex-remote-readable-{}", std::process::id()));
|
|
let file_path = allowed_dir.join("note.txt");
|
|
file_system
|
|
.create_directory(
|
|
&absolute_path(allowed_dir.clone()),
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
file_system
|
|
.write_file(
|
|
&absolute_path(file_path.clone()),
|
|
b"sandboxed hello".to_vec(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let sandbox = read_only_sandbox(allowed_dir.clone());
|
|
let contents = file_system
|
|
.read_file(&absolute_path(file_path.clone()), Some(&sandbox))
|
|
.await?;
|
|
assert_eq!(contents, b"sandboxed hello");
|
|
|
|
file_system
|
|
.remove(
|
|
&absolute_path(allowed_dir),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_sandboxed_read_rejects_symlink_parent_dotdot_escape() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!("/tmp/codex-remote-dotdot-{}", std::process::id()));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_dir = root.join("outside");
|
|
let secret_path = root.join("secret.txt");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside}; printf nope > {secret}; ln -s {outside} {allowed}/link",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside = outside_dir.display(),
|
|
secret = secret_path.display(),
|
|
))?;
|
|
|
|
let requested_path = absolute_path(allowed_dir.join("link").join("..").join("secret.txt"));
|
|
let sandbox = read_only_sandbox(allowed_dir.clone());
|
|
let error = match file_system.read_file(&requested_path, Some(&sandbox)).await {
|
|
Ok(_) => anyhow::bail!("read should fail after path normalization"),
|
|
Err(error) => error,
|
|
};
|
|
assert_normalized_path_rejected(&error);
|
|
|
|
remote_exec(&format!("rm -rf {}", root.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!(
|
|
"/tmp/codex-remote-remove-link-{}",
|
|
std::process::id()
|
|
));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_file = root.join("outside").join("keep.txt");
|
|
let symlink_path = allowed_dir.join("link");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside_parent}; printf outside > {outside}; ln -s {outside} {symlink}",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside_parent = absolute_path(
|
|
outside_file
|
|
.parent()
|
|
.context("outside parent should exist")?
|
|
.to_path_buf(),
|
|
)
|
|
.display(),
|
|
outside = outside_file.display(),
|
|
symlink = symlink_path.display(),
|
|
))?;
|
|
|
|
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
|
file_system
|
|
.remove(
|
|
&absolute_path(symlink_path.clone()),
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: false,
|
|
},
|
|
Some(&sandbox),
|
|
)
|
|
.await?;
|
|
|
|
let symlink_exists = file_system
|
|
.get_metadata(&absolute_path(symlink_path), /*sandbox*/ None)
|
|
.await
|
|
.is_ok();
|
|
assert!(!symlink_exists);
|
|
let outside = file_system
|
|
.read_file_text(&absolute_path(outside_file.clone()), /*sandbox*/ None)
|
|
.await?;
|
|
assert_eq!(outside, "outside");
|
|
|
|
file_system
|
|
.remove(
|
|
&absolute_path(root),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!(
|
|
"/tmp/codex-remote-copy-link-{}",
|
|
std::process::id()
|
|
));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_file = root.join("outside").join("outside.txt");
|
|
let source_symlink = allowed_dir.join("link");
|
|
let copied_symlink = allowed_dir.join("copied-link");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside_parent}; printf outside > {outside}; ln -s {outside} {source}",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside_parent = outside_file.parent().expect("outside parent").display(),
|
|
outside = outside_file.display(),
|
|
source = source_symlink.display(),
|
|
))?;
|
|
|
|
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
|
file_system
|
|
.copy(
|
|
&absolute_path(source_symlink),
|
|
&absolute_path(copied_symlink.clone()),
|
|
CopyOptions { recursive: false },
|
|
Some(&sandbox),
|
|
)
|
|
.await?;
|
|
|
|
let link_target = Command::new("docker")
|
|
.args([
|
|
"exec",
|
|
&get_remote_test_env()
|
|
.context("remote env should still be configured")?
|
|
.container_name,
|
|
"readlink",
|
|
copied_symlink
|
|
.to_str()
|
|
.context("copied symlink path should be utf-8")?,
|
|
])
|
|
.output()?;
|
|
assert!(
|
|
link_target.status.success(),
|
|
"readlink failed: stdout={} stderr={}",
|
|
String::from_utf8_lossy(&link_target.stdout).trim(),
|
|
String::from_utf8_lossy(&link_target.stderr).trim(),
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&link_target.stdout).trim(),
|
|
outside_file.to_string_lossy()
|
|
);
|
|
|
|
file_system
|
|
.remove(
|
|
&absolute_path(root),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn remote_test_file_path() -> PathBuf {
|
|
let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
|
Ok(duration) => duration.as_nanos(),
|
|
Err(_) => 0,
|
|
};
|
|
PathBuf::from(format!(
|
|
"/tmp/codex-remote-test-env-{}-{nanos}.txt",
|
|
std::process::id()
|
|
))
|
|
}
|