permissions: make runtime config profile-backed

This commit is contained in:
Michael Bolin
2026-04-25 22:39:53 -07:00
parent 9881dc7306
commit b7e4eadb35
67 changed files with 1641 additions and 681 deletions
@@ -359,6 +359,7 @@ use codex_rmcp_client::perform_oauth_login_return_url;
use codex_rollout::state_db::StateDbHandle;
use codex_rollout::state_db::get_state_db;
use codex_rollout::state_db::reconcile_rollout;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_state::StateRuntime;
use codex_state::ThreadMetadata;
use codex_state::ThreadMetadataBuilder;
@@ -2272,44 +2273,34 @@ impl CodexMessageProcessor {
arg0: None,
};
let (
effective_policy,
effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
) = if let Some(permission_profile) = permission_profile {
let effective_permission_profile = if let Some(permission_profile) = permission_profile {
let permission_profile =
codex_protocol::models::PermissionProfile::from(permission_profile);
let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) {
Ok(sandbox_policy) => sandbox_policy,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid permission profile: {err}"),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
};
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&self.config.permissions.file_system_sandbox_policy,
);
let effective_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&effective_permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_cwd.as_path(),
);
match self
.config
.permissions
.sandbox_policy
.can_set(&sandbox_policy)
{
Ok(()) => {
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&self.config.permissions.file_system_sandbox_policy,
);
(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
}
Ok(()) => effective_permission_profile,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -2327,7 +2318,13 @@ impl CodexMessageProcessor {
codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
(policy, file_system_sandbox_policy, network_sandbox_policy)
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(
&policy,
),
&file_system_sandbox_policy,
network_sandbox_policy,
)
}
Err(err) => {
let error = JSONRPCErrorError {
@@ -2340,11 +2337,7 @@ impl CodexMessageProcessor {
}
}
} else {
(
self.config.permissions.sandbox_policy.get().clone(),
self.config.permissions.file_system_sandbox_policy.clone(),
self.config.permissions.network_sandbox_policy,
)
self.config.permissions.permission_profile()
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
@@ -2363,9 +2356,7 @@ impl CodexMessageProcessor {
match codex_core::exec::build_exec_request(
exec_params,
&effective_policy,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
&effective_permission_profile,
&sandbox_cwd,
&codex_linux_sandbox_exe,
use_legacy_landlock,
@@ -10161,16 +10152,20 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path)
.permission_profile
.as_ref()
.is_some_and(|profile| {
profile
.to_legacy_sandbox_policy(cwd)
.is_ok_and(|sandbox_policy| {
matches!(
sandbox_policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
)
})
let (file_system_sandbox_policy, network_sandbox_policy) =
profile.to_runtime_permissions();
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
profile,
&file_system_sandbox_policy,
network_sandbox_policy,
cwd,
);
matches!(
sandbox_policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
)
})
}
@@ -10672,16 +10667,10 @@ mod tests {
#[test]
fn thread_response_permission_profile_preserves_enforcement() {
let full_access_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
);
let external_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
},
);
let full_access_profile = codex_protocol::models::PermissionProfile::Disabled;
let external_profile = codex_protocol::models::PermissionProfile::External {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
};
assert_eq!(
thread_response_permission_profile(external_profile.clone()),
@@ -10696,17 +10685,20 @@ mod tests {
#[test]
fn requested_permissions_trust_project_uses_permission_profile_intent() {
let cwd = test_path_buf("/tmp/project").abs();
let full_access_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
);
let workspace_write_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy(),
);
let read_only_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
let full_access_profile = codex_protocol::models::PermissionProfile::Disabled;
let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write();
let read_only_profile = codex_protocol::models::PermissionProfile::read_only();
let direct_write_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions(
&codex_protocol::permissions::FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: test_path_buf("/tmp/other").abs(),
},
access: FileSystemAccessMode::Write,
},
]),
codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
);
assert!(requested_permissions_trust_project(
@@ -10723,6 +10715,13 @@ mod tests {
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
permission_profile: Some(direct_write_profile),
..Default::default()
},
cwd.as_path()
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
permission_profile: Some(read_only_profile),
@@ -10915,10 +10914,7 @@ mod tests {
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
permission_profile:
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
),
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
cwd,
ephemeral: false,
reasoning_effort: None,
+7 -17
View File
@@ -710,9 +710,7 @@ mod tests {
use std::collections::HashMap;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[cfg(not(target_os = "windows"))]
@@ -729,12 +727,10 @@ mod tests {
use crate::outgoing_message::OutgoingMessage;
fn windows_sandbox_exec_request() -> ExecRequest {
let sandbox_policy = SandboxPolicy::ReadOnly {
network_access: false,
};
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
ExecRequest::new(
vec!["cmd".to_string()],
AbsolutePathBuf::current_dir().expect("current dir"),
cwd,
HashMap::new(),
/*network*/ None,
ExecExpiration::DefaultTimeout,
@@ -742,9 +738,7 @@ mod tests {
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
PermissionProfile::read_only(),
/*arg0*/ None,
)
}
@@ -834,9 +828,7 @@ mod tests {
connection_id: ConnectionId(8),
request_id: codex_app_server_protocol::RequestId::Integer(100),
};
let sandbox_policy = SandboxPolicy::ReadOnly {
network_access: false,
};
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
manager
.start(StartCommandExecParams {
@@ -845,7 +837,7 @@ mod tests {
process_id: Some("proc-100".to_string()),
exec_request: ExecRequest::new(
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
AbsolutePathBuf::current_dir().expect("current dir"),
cwd.clone(),
HashMap::new(),
/*network*/ None,
ExecExpiration::Cancellation(CancellationToken::new()),
@@ -853,9 +845,7 @@ mod tests {
SandboxType::None,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
PermissionProfile::read_only(),
/*arg0*/ None,
),
started_network_proxy: None,
+9 -5
View File
@@ -95,6 +95,8 @@ use tokio::time::timeout;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR: &str =
"CODEX_APP_SERVER_TEST_DISABLE_PLUGIN_STARTUP_TASKS";
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
@@ -315,11 +317,13 @@ impl MessageProcessor {
feedback,
log_db,
});
// Keep plugin startup warmups aligned at app-server startup.
// TODO(xl): Move into PluginManager once this no longer depends on config feature gating.
thread_manager
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
if std::env::var_os(TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR).is_none() {
// Keep plugin startup warmups aligned at app-server startup.
// TODO(xl): Move into PluginManager once this no longer depends on config feature gating.
thread_manager
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
}
let config_api = ConfigApi::new(
config_manager,
thread_manager.clone(),
+1
View File
@@ -26,6 +26,7 @@ pub use core_test_support::test_tmp_path;
pub use core_test_support::test_tmp_path_buf;
pub use mcp_process::DEFAULT_CLIENT_NAME;
pub use mcp_process::McpProcess;
pub use mcp_process::TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR;
pub use mock_model_server::create_mock_responses_server_repeating_assistant;
pub use mock_model_server::create_mock_responses_server_sequence;
pub use mock_model_server::create_mock_responses_server_sequence_unchecked;
@@ -106,6 +106,8 @@ pub struct McpProcess {
}
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
pub const TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR: &str =
"CODEX_APP_SERVER_TEST_DISABLE_PLUGIN_STARTUP_TASKS";
const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG";
impl McpProcess {
@@ -147,7 +149,8 @@ impl McpProcess {
cmd.stderr(Stdio::piped());
cmd.current_dir(codex_home);
cmd.env("CODEX_HOME", codex_home);
cmd.env("RUST_LOG", "info");
cmd.env("RUST_LOG", "warn");
cmd.env(TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR, "1");
// Keep integration tests isolated from host managed configuration.
cmd.env(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
+10 -1
View File
@@ -33,6 +33,7 @@ use std::process::Command;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2);
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -832,7 +833,7 @@ async fn maybe_fs_changed_notification(
mcp: &mut McpProcess,
) -> Result<Option<FsChangedNotification>> {
match timeout(
DEFAULT_READ_TIMEOUT,
OPTIONAL_FS_CHANGE_TIMEOUT,
mcp.read_stream_until_notification_message("fs/changed"),
)
.await
@@ -845,6 +846,14 @@ async fn maybe_fs_changed_notification(
fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> {
let temp_path = path.with_extension("lock");
std::fs::write(&temp_path, contents)?;
#[cfg(windows)]
match std::fs::remove_file(path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::rename(temp_path, path)?;
Ok(())
}
@@ -17,6 +17,9 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
#[cfg(windows)]
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
@@ -63,13 +66,14 @@ fn commit_marketplace_marker(root: &Path, marker: &str) -> Result<String> {
fn configured_git_marketplace_update<'a>(
source: &'a str,
last_revision: Option<&'a str>,
ref_name: Option<&'a str>,
) -> MarketplaceConfigUpdate<'a> {
MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision,
source_type: "git",
source,
ref_name: None,
ref_name,
sparse_paths: &[],
}
}
@@ -90,12 +94,13 @@ fn record_git_marketplace(
marketplace_name: &str,
source: &Path,
last_revision: &str,
ref_name: Option<&str>,
) -> Result<()> {
let source = source.display().to_string();
record_user_marketplace(
codex_home,
marketplace_name,
&configured_git_marketplace_update(&source, Some(last_revision)),
&configured_git_marketplace_update(&source, Some(last_revision), ref_name),
)?;
Ok(())
}
@@ -153,12 +158,14 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> {
"debug",
debug_source.path(),
&debug_old_revision,
Some(&debug_new_revision),
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
Some(&tools_new_revision),
)?;
disable_plugin_startup_tasks(codex_home.path())?;
@@ -205,12 +212,14 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> {
"debug",
debug_source.path(),
&debug_old_revision,
/*ref_name*/ None,
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
/*ref_name*/ None,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
@@ -246,7 +255,13 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re
let source = TempDir::new()?;
let old_revision = init_marketplace_repo(source.path(), "debug", "debug old")?;
commit_marketplace_marker(source.path(), "debug new")?;
record_git_marketplace(codex_home.path(), "debug", source.path(), &old_revision)?;
record_git_marketplace(
codex_home.path(),
"debug",
source.path(),
&old_revision,
/*ref_name*/ None,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -4,6 +4,7 @@ use anyhow::Result;
use anyhow::bail;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
@@ -1066,7 +1067,11 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
{
let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR, None)],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_path_exists(&marker_path).await?;
@@ -1102,7 +1107,11 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
{
let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR, None)],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
}
@@ -1490,7 +1499,11 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_DISABLE_PLUGIN_STARTUP_TASKS_ENV_VAR, None)],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?;
@@ -749,13 +749,17 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> {
#[tokio::test]
async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> {
let codex_home = TempDir::new()?;
let unsupported_write_root = TempDir::new()?;
let disallowed_write_root = TempDir::new()?;
create_config_toml(
codex_home.path(),
"http://localhost/unused",
"never",
&BTreeMap::from([(Feature::Personality, true)]),
)?;
std::fs::write(
codex_home.path().join("managed_config.toml"),
"sandbox_mode = \"read-only\"\n",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -772,7 +776,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let unsupported_write_root = AbsolutePathBuf::from_absolute_path(unsupported_write_root.path())
let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path())
.expect("tempdir path should be absolute");
let turn_req = mcp
@@ -787,7 +791,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: unsupported_write_root,
path: disallowed_write_root,
},
access: FileSystemAccessMode::Write,
}],
@@ -806,9 +810,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE);
assert!(err.error.message.contains("invalid turn context override"));
assert!(
err.error
.message
.contains("filesystem writes outside the workspace root")
err.error.message.contains("allowed set [ReadOnly]"),
"unexpected error message: {}",
err.error.message
);
let turn_started = tokio::time::timeout(
std::time::Duration::from_millis(250),