mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: make sandbox read access configurable with ReadOnlyAccess (#11387)
`SandboxPolicy::ReadOnly` previously implied broad read access and could
not express a narrower read surface.
This change introduces an explicit read-access model so we can support
user-configurable read restrictions in follow-up work, while preserving
current behavior today.
It also ensures unsupported backends fail closed for restricted-read
policies instead of silently granting broader access than intended.
## What
- Added `ReadOnlyAccess` in protocol with:
- `Restricted { include_platform_defaults, readable_roots }`
- `FullAccess`
- Updated `SandboxPolicy` to carry read-access configuration:
- `ReadOnly { access: ReadOnlyAccess }`
- `WorkspaceWrite { ..., read_only_access: ReadOnlyAccess }`
- Preserved existing behavior by defaulting current construction paths
to `ReadOnlyAccess::FullAccess`.
- Threaded the new fields through sandbox policy consumers and call
sites across `core`, `tui`, `linux-sandbox`, `windows-sandbox`, and
related tests.
- Updated Seatbelt policy generation to honor restricted read roots by
emitting scoped read rules when full read access is not granted.
- Added fail-closed behavior on Linux and Windows backends when
restricted read access is requested but not yet implemented there
(`UnsupportedOperation`).
- Regenerated app-server protocol schema and TypeScript artifacts,
including `ReadOnlyAccess`.
## Compatibility / rollout
- Runtime behavior remains unchanged by default (`FullAccess`).
- API/schema changes are in place so future config wiring can enable
restricted read access without another policy-shape migration.
This commit is contained in:
@@ -16,7 +16,7 @@ pub fn add_dir_warning_message(
|
||||
SandboxPolicy::WorkspaceWrite { .. }
|
||||
| SandboxPolicy::DangerFullAccess
|
||||
| SandboxPolicy::ExternalSandbox { .. } => None,
|
||||
SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)),
|
||||
SandboxPolicy::ReadOnly { .. } => Some(format_warning(additional_dirs)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn warns_for_read_only() {
|
||||
let sandbox = SandboxPolicy::ReadOnly;
|
||||
let sandbox = SandboxPolicy::new_read_only_policy();
|
||||
let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")];
|
||||
let message = add_dir_warning_message(&dirs, &sandbox)
|
||||
.expect("expected warning for read-only sandbox");
|
||||
@@ -76,7 +76,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_additional_dirs() {
|
||||
let sandbox = SandboxPolicy::ReadOnly;
|
||||
let sandbox = SandboxPolicy::new_read_only_policy();
|
||||
let dirs: Vec<PathBuf> = Vec::new();
|
||||
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
|
||||
}
|
||||
|
||||
@@ -1158,7 +1158,7 @@ impl App {
|
||||
&& matches!(
|
||||
app.config.sandbox_policy.get(),
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly { .. }
|
||||
)
|
||||
&& !app
|
||||
.config
|
||||
@@ -2016,9 +2016,8 @@ impl App {
|
||||
let policy_is_workspace_write_or_ro = matches!(
|
||||
&policy,
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly { .. }
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
let policy_for_chat = policy.clone();
|
||||
|
||||
if let Err(err) = self.config.sandbox_policy.set(policy) {
|
||||
@@ -2027,7 +2026,6 @@ impl App {
|
||||
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) {
|
||||
tracing::warn!(%err, "failed to set sandbox policy on chat config");
|
||||
self.chat_widget
|
||||
@@ -3081,7 +3079,7 @@ mod tests {
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
@@ -3136,7 +3134,7 @@ mod tests {
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
@@ -3185,7 +3183,7 @@ mod tests {
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
@@ -3264,7 +3262,7 @@ mod tests {
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
@@ -3388,7 +3386,7 @@ mod tests {
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
|
||||
@@ -5540,7 +5540,7 @@ impl ChatWidget {
|
||||
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let describe_policy = |policy: &SandboxPolicy| match policy {
|
||||
SandboxPolicy::WorkspaceWrite { .. } => "Agent mode",
|
||||
SandboxPolicy::ReadOnly => "Read-Only mode",
|
||||
SandboxPolicy::ReadOnly { .. } => "Read-Only mode",
|
||||
_ => "Agent mode",
|
||||
};
|
||||
let mode_label = preset
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
@@ -219,7 +219,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
@@ -337,7 +337,7 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
@@ -417,7 +417,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
@@ -3105,7 +3105,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
@@ -4180,6 +4180,7 @@ async fn preset_matching_requires_exact_workspace_write_settings() {
|
||||
.expect("auto preset exists");
|
||||
let current_sandbox = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
|
||||
@@ -474,13 +474,14 @@ mod tests {
|
||||
} else {
|
||||
absolute_path("/etc/codex/requirements.toml")
|
||||
};
|
||||
|
||||
let requirements = ConfigRequirements {
|
||||
approval_policy: ConstrainedWithSource::new(
|
||||
Constrained::allow_any(AskForApproval::OnRequest),
|
||||
Some(RequirementSource::CloudRequirements),
|
||||
),
|
||||
sandbox_policy: ConstrainedWithSource::new(
|
||||
Constrained::allow_any(SandboxPolicy::ReadOnly),
|
||||
Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
Some(RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_file.clone(),
|
||||
}),
|
||||
@@ -572,7 +573,6 @@ mod tests {
|
||||
));
|
||||
assert!(!rendered.contains(" - rules:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_config_output_lists_session_flag_key_value_pairs() {
|
||||
let session_flags = toml::from_str::<TomlValue>(
|
||||
|
||||
@@ -193,7 +193,7 @@ impl StatusHistoryCell {
|
||||
.unwrap_or_else(|| "<unknown>".to_string());
|
||||
let sandbox = match config.sandbox_policy.get() {
|
||||
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
|
||||
SandboxPolicy::ReadOnly => "read-only".to_string(),
|
||||
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: true,
|
||||
..
|
||||
|
||||
@@ -101,6 +101,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
@@ -183,6 +184,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() {
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: Default::default(),
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
|
||||
Reference in New Issue
Block a user