mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Reject lossy legacy permission projections (#39117)
## Why Some managed filesystem permission profiles cannot be represented by the legacy app-server sandbox policy without changing which paths are accessible. ## What changed - Require legacy sandbox conversion to preserve the filesystem policy's semantics, and reject unsupported profiles with an actionable error. - Keep queued follow-up messages and safety-buffered retries intact when permissions are rejected, resuming queued input only after a compatible profile is selected. - Cover compatible profiles, lossy restrictions and write roots, turn-start handling, and safety-buffered retries with regression tests. GitOrigin-RevId: 345cc2a839206b2b1997e8981feb59f91b6975cc
This commit is contained in:
@@ -7,6 +7,8 @@ use super::resize_reflow::trailing_run_start;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::app_server_session::ForkGoalContinuation;
|
||||
use crate::app_server_session::UnsupportedLegacyPermissionProfile;
|
||||
use crate::app_server_session::turn_permissions_overrides;
|
||||
use crate::config_update::format_config_error;
|
||||
use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome;
|
||||
use crate::pager_overlay::TranscriptHistoryState;
|
||||
@@ -664,12 +666,19 @@ impl App {
|
||||
}
|
||||
self.chat_widget.prepare_local_op_submission(&op);
|
||||
if let Err(err) = self.submit_active_thread_op(app_server, op).await {
|
||||
let unsupported_permissions = err
|
||||
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
|
||||
.is_some();
|
||||
if unsupported_permissions {
|
||||
self.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
}
|
||||
let handled = is_user_turn
|
||||
&& matches!(
|
||||
&& (matches!(
|
||||
err.downcast_ref::<TypedRequestError>(),
|
||||
Some(TypedRequestError::Server { method, .. })
|
||||
if method == "turn/start"
|
||||
)
|
||||
) || unsupported_permissions)
|
||||
&& self
|
||||
.chat_widget
|
||||
.handle_turn_start_rejection(format!("Failed to start turn: {err:#}"));
|
||||
@@ -1404,9 +1413,21 @@ impl App {
|
||||
}
|
||||
AppEvent::SettingsSelectionSettled => {
|
||||
if self.chat_widget.no_modal_or_popup_active() {
|
||||
self.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
let config = self.chat_widget.config_ref();
|
||||
let permissions_override = Self::turn_permissions_override_from_config(
|
||||
config,
|
||||
config.permissions.active_permission_profile().as_ref(),
|
||||
self.runtime_permission_profile_override
|
||||
.as_ref()
|
||||
.map(|profile| &profile.permission_profile),
|
||||
);
|
||||
if turn_permissions_overrides(permissions_override, config.cwd.as_path())
|
||||
.is_ok()
|
||||
{
|
||||
self.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::app_server_session::ForkGoalContinuation;
|
||||
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
|
||||
use crate::app_server_session::turn_permissions_overrides;
|
||||
use crate::chatwidget::ThreadInputState;
|
||||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||||
use crate::chatwidget::UserMessage;
|
||||
@@ -52,6 +53,8 @@ impl App {
|
||||
|
||||
let AppCommand::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
active_permission_profile,
|
||||
model: turn_model,
|
||||
effort,
|
||||
collaboration_mode,
|
||||
@@ -63,6 +66,18 @@ impl App {
|
||||
);
|
||||
return;
|
||||
};
|
||||
let permissions_override = Self::turn_permissions_override_from_config(
|
||||
&retry_config,
|
||||
active_permission_profile.as_ref(),
|
||||
self.runtime_permission_profile_override
|
||||
.as_ref()
|
||||
.map(|profile| &profile.permission_profile),
|
||||
);
|
||||
if let Err(err) = turn_permissions_overrides(permissions_override, cwd.as_path()) {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to retry with a faster model: {err}"));
|
||||
return;
|
||||
}
|
||||
*turn_model = model.clone();
|
||||
*effort = Some(ReasoningEffortConfig::Low);
|
||||
*collaboration_mode = collaboration_mode.as_ref().map(|mode| {
|
||||
|
||||
@@ -4,6 +4,12 @@ use crate::app::session_lifecycle::ThreadAttachPresentation;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -31,6 +37,7 @@ enum SafetyRetryScenario {
|
||||
Once,
|
||||
RetryTwice,
|
||||
InterruptedPrevious,
|
||||
UnsupportedPermissions,
|
||||
}
|
||||
|
||||
fn response_chunks(response_id: &str) -> Vec<StreamingSseChunk> {
|
||||
@@ -483,7 +490,7 @@ goals = true
|
||||
.expect("source goal usage should be recorded");
|
||||
|
||||
submit_prompt(&mut app, RETRY_PROMPT);
|
||||
let active_turn = next_user_turn_event(&mut app_event_rx);
|
||||
let mut active_turn = next_user_turn_event(&mut app_event_rx);
|
||||
app.submit_thread_op(&mut app_server, source_thread_id, active_turn.clone())
|
||||
.await?;
|
||||
let active_turn_id = next_turn_started(&mut app, &mut app_server, source_thread_id).await;
|
||||
@@ -572,12 +579,55 @@ goals = true
|
||||
app.primary_thread_id = Some(source_thread_id);
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
|
||||
if scenario == SafetyRetryScenario::UnsupportedPermissions {
|
||||
let extra_root =
|
||||
AbsolutePathBuf::resolve_path_against_base("extra", app.config.cwd.as_path());
|
||||
let permission_profile = PermissionProfile::Managed {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
file_system: ManagedFileSystemPermissions::Restricted {
|
||||
entries: vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: extra_root.into(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
};
|
||||
app.config
|
||||
.permissions
|
||||
.set_permission_profile(permission_profile.clone())?;
|
||||
app.chat_widget.set_permission_profile_with_active_profile(
|
||||
permission_profile,
|
||||
/*active_permission_profile*/ None,
|
||||
)?;
|
||||
app.runtime_permission_profile_override =
|
||||
Some(RuntimePermissionProfileOverride::from_config(&app.config));
|
||||
if let AppCommand::UserTurn {
|
||||
active_permission_profile,
|
||||
..
|
||||
} = &mut active_turn
|
||||
{
|
||||
*active_permission_profile = None;
|
||||
}
|
||||
}
|
||||
|
||||
Box::pin(app.retry_safety_buffered_turn(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
SafetyBufferedRetry {
|
||||
thread_id: source_thread_id,
|
||||
turn_id: active_turn_id,
|
||||
turn_id: active_turn_id.clone(),
|
||||
model: FASTER_MODEL.to_string(),
|
||||
turn: active_turn,
|
||||
prompt: UserMessage::from(RETRY_PROMPT),
|
||||
@@ -585,6 +635,42 @@ goals = true
|
||||
))
|
||||
.await;
|
||||
|
||||
if scenario == SafetyRetryScenario::UnsupportedPermissions {
|
||||
assert_eq!(app.active_thread_id, Some(source_thread_id));
|
||||
assert_eq!(app.primary_thread_id, Some(source_thread_id));
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id));
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.can_retry_safety_buffered_turn(&active_turn_id)
|
||||
);
|
||||
let source = app_server
|
||||
.thread_read(source_thread_id, /*include_turns*/ true)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
source.turns.last().map(|turn| &turn.status),
|
||||
Some(&TurnStatus::InProgress)
|
||||
);
|
||||
let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||||
.find_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.expect("unsupported permissions should be added to history");
|
||||
insta::assert_snapshot!(
|
||||
lines_to_single_string(&error_cell.display_lines(/*width*/ 200)),
|
||||
@"■ Failed to retry with a faster model: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
||||
);
|
||||
if let Some(release_active_response) = release_active_response.take() {
|
||||
let _ = release_active_response.send(());
|
||||
}
|
||||
let _ = release_steered_response.send(());
|
||||
let _ = release_previous_response.send(());
|
||||
let _ = release_retry_response.send(());
|
||||
app_server.shutdown().await?;
|
||||
server.shutdown().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let first_retry_thread_id = app.chat_widget.thread_id().expect("first retry thread id");
|
||||
if scenario == SafetyRetryScenario::RetryTwice {
|
||||
let first_retry_turn_id =
|
||||
@@ -890,3 +976,14 @@ async fn safety_retry_branch_failure_preserves_unsent_draft() -> Result<()> {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn safety_retry_rejects_unsupported_permissions_before_interrupting() -> Result<()> {
|
||||
run_safety_retry(
|
||||
/*previous_prompt*/ None,
|
||||
/*failing_draft*/ None,
|
||||
/*committed_steer*/ None,
|
||||
SafetyRetryScenario::UnsupportedPermissions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_failure_is_shown_without_exiting() -> Result<()> {
|
||||
@@ -50,3 +56,110 @@ async fn turn_start_failure_is_shown_without_exiting() -> Result<()> {
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_legacy_permissions_are_shown_without_exiting() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let thread_id = ThreadId::new();
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
|
||||
|
||||
let extra_root = codex_utils_absolute_path::AbsolutePathBuf::resolve_path_against_base(
|
||||
"extra",
|
||||
app.config.cwd.as_path(),
|
||||
);
|
||||
let permission_profile = PermissionProfile::Managed {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
file_system: ManagedFileSystemPermissions::Restricted {
|
||||
entries: vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: extra_root.into(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
};
|
||||
app.config
|
||||
.permissions
|
||||
.set_permission_profile(permission_profile.clone())?;
|
||||
app.chat_widget.set_permission_profile_with_active_profile(
|
||||
permission_profile,
|
||||
/*active_permission_profile*/ None,
|
||||
)?;
|
||||
app.runtime_permission_profile_override =
|
||||
Some(RuntimePermissionProfileOverride::from_config(&app.config));
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer(UserMessage::from("hello"));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let op = next_user_turn_op(&mut op_rx);
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer(UserMessage::from("queued follow-up"));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(app.chat_widget.has_queued_follow_up_messages());
|
||||
while let Ok(event) = app_event_rx.try_recv() {
|
||||
app.handle_event(&mut tui, &mut app_server, event).await?;
|
||||
}
|
||||
|
||||
let control = app
|
||||
.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op))
|
||||
.await?;
|
||||
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
assert!(app.chat_widget.has_queued_follow_up_messages());
|
||||
let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||||
.find_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.expect("unsupported permissions should be added to history");
|
||||
insta::assert_snapshot!(
|
||||
lines_to_single_string(&error_cell.display_lines(/*width*/ 200)),
|
||||
@"■ Failed to start turn: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
||||
);
|
||||
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::SettingsSelectionSettled,
|
||||
)
|
||||
.await?;
|
||||
assert!(app.chat_widget.has_queued_follow_up_messages());
|
||||
|
||||
app.config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())?;
|
||||
app.chat_widget.set_permission_profile_with_active_profile(
|
||||
PermissionProfile::read_only(),
|
||||
/*active_permission_profile*/ None,
|
||||
)?;
|
||||
app.runtime_permission_profile_override =
|
||||
Some(RuntimePermissionProfileOverride::from_config(&app.config));
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::SettingsSelectionSettled,
|
||||
)
|
||||
.await?;
|
||||
assert!(!app.chat_widget.has_queued_follow_up_messages());
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -828,7 +828,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_permissions_override_from_config(
|
||||
pub(super) fn turn_permissions_override_from_config(
|
||||
config: &Config,
|
||||
active_permission_profile: Option<&ActivePermissionProfile>,
|
||||
runtime_permission_profile_override: Option<&PermissionProfile>,
|
||||
|
||||
@@ -14,7 +14,6 @@ pub(crate) use history::thread_items_page_params;
|
||||
|
||||
use crate::bottom_pane::FeedbackAudience;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::permission_compat::legacy_compatible_permission_profile;
|
||||
use crate::service_tier_resolution;
|
||||
use crate::session_state::MessageHistoryMetadata;
|
||||
use crate::session_state::ThreadSessionState;
|
||||
@@ -335,6 +334,12 @@ pub(crate) enum TurnPermissionsOverride {
|
||||
LegacySandbox(PermissionProfile),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error(
|
||||
"the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
||||
)]
|
||||
pub(crate) struct UnsupportedLegacyPermissionProfile;
|
||||
|
||||
impl AppServerSession {
|
||||
pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self {
|
||||
Self {
|
||||
@@ -1051,7 +1056,7 @@ impl AppServerSession {
|
||||
) -> Result<TurnStartResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
let (sandbox_policy, permissions) =
|
||||
turn_permissions_overrides(permissions_override, cwd.as_path());
|
||||
turn_permissions_overrides(permissions_override, cwd.as_path())?;
|
||||
self.client
|
||||
.request_typed(ClientRequest::TurnStart {
|
||||
request_id,
|
||||
@@ -1607,14 +1612,14 @@ fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) ->
|
||||
active.id
|
||||
}
|
||||
|
||||
fn turn_permissions_overrides(
|
||||
pub(crate) fn turn_permissions_overrides(
|
||||
permissions_override: TurnPermissionsOverride,
|
||||
cwd: &std::path::Path,
|
||||
) -> (
|
||||
) -> Result<(
|
||||
Option<codex_app_server_protocol::SandboxPolicy>,
|
||||
Option<String>,
|
||||
) {
|
||||
match permissions_override {
|
||||
)> {
|
||||
Ok(match permissions_override {
|
||||
TurnPermissionsOverride::Preserve => (None, None),
|
||||
TurnPermissionsOverride::ActiveProfile(active_permission_profile) => (
|
||||
None,
|
||||
@@ -1623,17 +1628,20 @@ fn turn_permissions_overrides(
|
||||
)),
|
||||
),
|
||||
TurnPermissionsOverride::LegacySandbox(permission_profile) => {
|
||||
let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd);
|
||||
let policy = legacy_profile
|
||||
let policy = permission_profile
|
||||
.to_legacy_sandbox_policy(cwd)
|
||||
.unwrap_or_else(|err| {
|
||||
unreachable!(
|
||||
"legacy-compatible permissions must project to legacy policy: {err}"
|
||||
)
|
||||
});
|
||||
.map_err(|_| UnsupportedLegacyPermissionProfile)?;
|
||||
let projected_profile =
|
||||
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd);
|
||||
if !permission_profile
|
||||
.file_system_sandbox_policy()
|
||||
.is_semantically_equivalent_to(&projected_profile.file_system_sandbox_policy(), cwd)
|
||||
{
|
||||
return Err(UnsupportedLegacyPermissionProfile.into());
|
||||
}
|
||||
(Some(policy.into()), None)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permissions_selection_from_config(
|
||||
@@ -2426,7 +2434,8 @@ mod tests {
|
||||
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
||||
cwd.as_path(),
|
||||
);
|
||||
)
|
||||
.expect("active permission profile should be supported");
|
||||
|
||||
assert_eq!(sandbox_policy, None);
|
||||
assert_eq!(permissions, Some(expected_permissions));
|
||||
@@ -2441,7 +2450,8 @@ mod tests {
|
||||
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
||||
cwd.as_path(),
|
||||
);
|
||||
)
|
||||
.expect("active permission profile should be supported");
|
||||
|
||||
assert_eq!(sandbox_policy, None);
|
||||
assert_eq!(
|
||||
@@ -2455,7 +2465,8 @@ mod tests {
|
||||
let cwd = test_path_buf("/workspace/project").abs();
|
||||
|
||||
let (sandbox_policy, permissions) =
|
||||
turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path());
|
||||
turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path())
|
||||
.expect("preserving permissions should be supported");
|
||||
|
||||
assert_eq!(sandbox_policy, None);
|
||||
assert_eq!(permissions, None);
|
||||
@@ -2468,7 +2479,8 @@ mod tests {
|
||||
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()),
|
||||
cwd.as_path(),
|
||||
);
|
||||
)
|
||||
.expect("read-only permission profile should be supported");
|
||||
|
||||
assert_eq!(
|
||||
sandbox_policy,
|
||||
@@ -2479,6 +2491,118 @@ mod tests {
|
||||
assert_eq!(permissions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_turn_permissions_preserve_workspace_write() {
|
||||
let cwd = test_path_buf("/workspace/project").abs();
|
||||
|
||||
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::LegacySandbox(PermissionProfile::workspace_write()),
|
||||
cwd.as_path(),
|
||||
)
|
||||
.expect("workspace-write permission profile should be supported");
|
||||
|
||||
assert_eq!(
|
||||
sandbox_policy,
|
||||
Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(permissions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_turn_permissions_reject_non_cwd_write_roots() {
|
||||
let cwd = test_path_buf("/workspace/project").abs();
|
||||
let extra_root = test_path_buf("/workspace/extra").abs();
|
||||
let permission_profile = PermissionProfile::Managed {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
file_system: ManagedFileSystemPermissions::Restricted {
|
||||
entries: vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: extra_root.into(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
};
|
||||
|
||||
let error = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::LegacySandbox(permission_profile),
|
||||
cwd.as_path(),
|
||||
)
|
||||
.expect_err("non-cwd write roots must not grant cwd write access");
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_turn_permissions_reject_restrictions_lost_by_projection() {
|
||||
let cwd = test_path_buf("/workspace/project").abs();
|
||||
let docs = test_path_buf("/workspace/project/docs").abs();
|
||||
|
||||
for (path, access) in [
|
||||
(
|
||||
FileSystemPath::Path {
|
||||
path: docs.clone().into(),
|
||||
},
|
||||
FileSystemAccessMode::Read,
|
||||
),
|
||||
(
|
||||
FileSystemPath::Path { path: docs.into() },
|
||||
FileSystemAccessMode::Deny,
|
||||
),
|
||||
(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.secret".to_string(),
|
||||
},
|
||||
FileSystemAccessMode::Deny,
|
||||
),
|
||||
] {
|
||||
let mut permission_profile = PermissionProfile::workspace_write();
|
||||
let PermissionProfile::Managed {
|
||||
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
|
||||
..
|
||||
} = &mut permission_profile
|
||||
else {
|
||||
unreachable!("workspace-write profiles use restricted managed permissions");
|
||||
};
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path,
|
||||
access,
|
||||
missing_path_behavior: None,
|
||||
});
|
||||
|
||||
let error = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::LegacySandbox(permission_profile),
|
||||
cwd.as_path(),
|
||||
)
|
||||
.expect_err("legacy projection must not discard filesystem restrictions");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_turn_permissions_preserve_active_profile_selection() {
|
||||
let cwd = test_path_buf("/workspace/project").abs();
|
||||
@@ -2489,7 +2613,8 @@ mod tests {
|
||||
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
||||
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
||||
cwd.as_path(),
|
||||
);
|
||||
)
|
||||
.expect("active permission profile should be supported");
|
||||
|
||||
assert_eq!(sandbox_policy, None);
|
||||
assert_eq!(permissions, Some(expected_permissions));
|
||||
|
||||
@@ -160,7 +160,6 @@ mod npm_registry;
|
||||
pub(crate) mod onboarding;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
mod permission_compat;
|
||||
pub(crate) mod public_widgets;
|
||||
mod render;
|
||||
mod resize_reflow_cap;
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
//! Compatibility projections from the canonical permission profile model into
|
||||
//! legacy shapes still required by older or remote app-server APIs.
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn legacy_compatible_permission_profile(
|
||||
permission_profile: &PermissionProfile,
|
||||
cwd: &Path,
|
||||
) -> PermissionProfile {
|
||||
if permission_profile.to_legacy_sandbox_policy(cwd).is_ok() {
|
||||
return permission_profile.clone();
|
||||
}
|
||||
|
||||
let file_system_policy = permission_profile.file_system_sandbox_policy();
|
||||
let network_policy = permission_profile.network_sandbox_policy();
|
||||
let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
let writable_roots = file_system_policy
|
||||
.get_writable_roots_with_cwd(cwd)
|
||||
.into_iter()
|
||||
.map(|root| root.root)
|
||||
.filter(|root| cwd_abs.as_ref() != Some(root))
|
||||
.collect::<Vec<_>>();
|
||||
let tmpdir_writable = std::env::var_os("TMPDIR")
|
||||
.filter(|tmpdir| !tmpdir.is_empty())
|
||||
.and_then(|tmpdir| {
|
||||
AbsolutePathBuf::from_absolute_path(std::path::PathBuf::from(tmpdir)).ok()
|
||||
})
|
||||
.is_some_and(|tmpdir| file_system_policy.can_write_path_with_cwd(tmpdir.as_path(), cwd));
|
||||
let slash_tmp = Path::new("/tmp");
|
||||
let slash_tmp_writable = slash_tmp.is_absolute()
|
||||
&& slash_tmp.is_dir()
|
||||
&& file_system_policy.can_write_path_with_cwd(slash_tmp, cwd);
|
||||
|
||||
PermissionProfile::workspace_write_with(
|
||||
&writable_roots,
|
||||
network_policy,
|
||||
!tmpdir_writable,
|
||||
!slash_tmp_writable,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn compatibility_profile_preserves_unbridgeable_write_roots() {
|
||||
let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd");
|
||||
let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root");
|
||||
let permission_profile: PermissionProfile = PermissionProfile::Managed {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
file_system: ManagedFileSystemPermissions::Restricted {
|
||||
entries: vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: extra_root.clone().into(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
missing_path_behavior: None,
|
||||
},
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
};
|
||||
|
||||
let compatibility_profile =
|
||||
legacy_compatible_permission_profile(&permission_profile, cwd.as_path());
|
||||
let policy = compatibility_profile
|
||||
.to_legacy_sandbox_policy(cwd.as_path())
|
||||
.expect("compatibility profile should project to legacy policy");
|
||||
let roots = policy
|
||||
.get_writable_roots_with_cwd(cwd.as_path())
|
||||
.into_iter()
|
||||
.map(|root| root.root)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(roots, vec![extra_root, cwd]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user