permissions: support workspace roots in profiles (#22610)

## Why

This is the configuration/model half of the alternative permissions
migration we discussed as a comparison point for
[#22401](https://github.com/openai/codex/pull/22401) and
[#22402](https://github.com/openai/codex/pull/22402).

The old `workspace-write` model mixes three concerns that we want to
keep separate:
- reusable profile rules that should stay immutable once selected
- user/runtime workspace roots from `cwd`, `--add-dir`, and legacy
workspace-write config
- internal Codex writable roots such as memories, which should not be
shown as user workspace roots

This PR gives permission profiles first-class `workspace_roots` so users
can opt multiple repositories into the same `:workspace_roots` rules
without using broad absolute-path write grants. It also starts
separating the raw selected profile from the effective runtime profile
by making `Permissions` expose explicit accessors instead of public
mutable fields.

A representative `config.toml` looks like this:

```toml
default_permissions = "dev"

[permissions.dev.workspace_roots]
"~/code/openai" = true
"~/code/developers-website" = true

[permissions.dev.filesystem.":workspace_roots"]
"." = "write"
".codex" = "read"
".git" = "read"
".vscode" = "read"
```

If Codex starts in `~/code/codex` with that profile selected, the
effective workspace-root set becomes:
- `~/code/codex` from the runtime `cwd`
- `~/code/openai` from the profile
- `~/code/developers-website` from the profile

The `:workspace_roots` rules are materialized across each root, so
`.git`, `.codex`, and `.vscode` stay scoped the same way everywhere.
Runtime additions such as `--add-dir` can still layer on later stack
entries without mutating the selected profile.

## Stack Shape

This PR intentionally stops before the profile-identity cleanup in
[#22683](https://github.com/openai/codex/pull/22683) so the base review
stays focused on config loading, workspace-root materialization, and
compatibility with legacy `workspace-write`.

The representation in this PR is therefore transitional: `Permissions`
carries enough state to distinguish the raw constrained profile from the
effective runtime profile, and there are still call sites that must keep
the active profile identity and constrained profile value in sync. The
follow-up PR replaces that with a single resolved profile state
(`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the
profile id, immutable `PermissionProfile`, and profile-declared
workspace roots together. That follow-up removes APIs such as
`set_constrained_permission_profile_with_active_profile()` where
separate arguments could drift out of sync.

Downstream PRs then build on this base to switch app-server turn updates
to profile ids plus runtime workspace roots and to finish the
user-visible summary behavior. Reviewers should judge this PR as the
workspace-roots foundation, not as the final in-memory shape of selected
permission profiles.

## Review Guide

Suggested review order:

1. Start with `codex-rs/core/src/config/mod.rs`.
This is the main shape change in the base slice. `Permissions` now
stores a private raw `Constrained<PermissionProfile>` plus runtime
`workspace_roots`. Callers use `permission_profile()` when they need the
raw constrained value and `effective_permission_profile()` when they
need a materialized runtime profile. As noted above,
[#22683](https://github.com/openai/codex/pull/22683) replaces this
transitional shape with a resolved profile state that keeps identity and
profile data together.

2. Review `codex-rs/config/src/permissions_toml.rs` and
`codex-rs/core/src/config/permissions.rs`.
These add `[permissions.<id>.workspace_roots]`, resolve enabled entries
relative to the policy cwd, and keep `:workspace_roots` deny-read glob
patterns symbolic until the actual roots are known.

3. Review `codex-rs/protocol/src/permissions.rs` and
`codex-rs/protocol/src/models.rs`.
These add the policy/profile materialization helpers that expand exact
`:workspace_roots` entries and scoped deny-read globs over every
workspace root. This is also where `ActivePermissionProfileModification`
is removed from the core model.

4. Review the legacy bridge in
`Config::load_from_base_config_with_overrides` and
`Config::set_legacy_sandbox_policy`.
This is where legacy `workspace-write` roots become runtime workspace
roots, while Codex internal writable roots stay internal and do not
appear as user-facing workspace roots.

5. Then skim downstream call sites.
The interesting pattern is raw-vs-effective access: state/proxy/bwrap
paths keep the raw constrained profile, while execution, summaries, and
user-visible status use the effective profile and workspace-root list.

## What Changed

- added `[permissions.<id>.workspace_roots]` to the config model and
schema
- added runtime `workspace_roots` state to `Config`/`Permissions` and
`ConfigOverrides`
- made `Permissions` profile fields private and replaced direct mutation
with accessors/setters
- added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for
materializing `:workspace_roots` exact paths and deny-read globs across
all roots
- moved legacy additional writable roots into runtime workspace-root
state instead of active profile modifications
- removed `ActivePermissionProfileModification` and its app-server
protocol/schema export
- updated sandbox/status summary paths so internal writable roots are
not reported as user workspace roots

## Verification Strategy

The targeted tests cover the behavior at the layers where regressions
are most likely:
- `codex-rs/core/src/config/config_tests.rs` verifies config loading,
legacy workspace-root seeding, effective profile materialization, and
memory-root handling.
- `codex-rs/core/src/config/permissions_tests.rs` verifies profile
`workspace_roots` parsing and `:workspace_roots` scoped/glob
compilation.
- `codex-rs/protocol/src/permissions.rs` unit tests verify exact and
glob materialization over multiple workspace roots.
- `codex-rs/tui/src/status/tests.rs` and
`codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the
user-facing summaries show effective workspace roots and hide internal
writes.

I also ran `cargo check --tests` locally after the latest stack refresh
to catch cross-crate API breakage from the private-field/accessor
changes.







---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610).
* #22612
* #22611
* #22683
* __->__ #22610
This commit is contained in:
Michael Bolin
2026-05-14 18:25:23 -07:00
committed by GitHub
parent 7dbe1c9498
commit c25d905f61
60 changed files with 1355 additions and 731 deletions
+1 -1
View File
@@ -957,7 +957,7 @@ See the Codex keymap documentation for supported actions and examples."
// world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let startup_permission_profile = app.config.permissions.permission_profile();
let startup_permission_profile = app.config.permissions.effective_permission_profile();
let should_check = WindowsSandboxLevel::from_config(&app.config)
!= WindowsSandboxLevel::Disabled
&& managed_filesystem_sandbox_is_restricted(&startup_permission_profile)
+7 -5
View File
@@ -299,10 +299,13 @@ impl App {
self.config.permissions.approval_policy.value(),
));
}
if permission_profile_override.is_some()
let permission_profile_override_value = permission_profile_override
.is_some()
.then(|| self.config.permissions.permission_profile().get().clone());
if let Some(permission_profile) = permission_profile_override_value.as_ref()
&& let Err(err) = self
.chat_widget
.set_permission_profile(self.config.permissions.permission_profile())
.set_permission_profile(permission_profile.clone())
{
tracing::error!(
error = %err,
@@ -311,9 +314,8 @@ impl App {
self.chat_widget
.add_error_message(format!("Failed to enable Auto-review: {err}"));
}
if permission_profile_override.is_some() {
self.runtime_permission_profile_override =
Some(self.config.permissions.permission_profile());
if let Some(permission_profile) = permission_profile_override_value {
self.runtime_permission_profile_override = Some(permission_profile);
}
if approval_policy_override.is_some()
+3 -2
View File
@@ -1426,7 +1426,7 @@ impl App {
return Ok(AppRunControl::Continue);
}
self.runtime_permission_profile_override =
Some(self.config.permissions.permission_profile());
Some(self.config.permissions.permission_profile().get().clone());
self.sync_active_thread_permission_settings_to_cached_session()
.await;
@@ -1450,7 +1450,8 @@ impl App {
std::env::vars().collect();
let tx = self.app_event_tx.clone();
let logs_base_dir = self.config.codex_home.clone();
let permission_profile = self.config.permissions.permission_profile();
let permission_profile =
self.config.permissions.effective_permission_profile();
Self::spawn_world_writable_scan(
cwd,
env_map,
+1 -1
View File
@@ -67,7 +67,7 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config
pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) {
let Some(message) =
codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile.get())
codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile().get())
else {
return;
};
+11 -7
View File
@@ -1639,8 +1639,9 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
auto_review.permission_profile
.permission_profile()
.get(),
&auto_review.permission_profile
);
assert_eq!(
app.chat_widget.config_ref().approvals_reviewer,
@@ -1816,8 +1817,9 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
auto_review.permission_profile
.permission_profile()
.get(),
&auto_review.permission_profile
);
assert_eq!(
op_rx.try_recv(),
@@ -3001,7 +3003,9 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
.chat_widget
.config_ref()
.permissions
.permission_profile();
.permission_profile()
.get()
.clone();
assert_eq!(
session.permission_profile, expected_permission_profile,
"thread/read does not return fresh server permissions; the fallback profile must use the \
@@ -3136,7 +3140,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() {
fork_config.model_reasoning_effort,
fork_config.service_tier.as_deref(),
fork_config.permissions.approval_policy.value(),
fork_config.permissions.permission_profile(),
fork_config.permissions.permission_profile().get(),
fork_config.approvals_reviewer,
),
(
@@ -3144,7 +3148,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() {
Some(ReasoningEffortConfig::High),
Some(parent_service_tier),
AskForApproval::OnRequest.to_core(),
parent_permission_profile,
&parent_permission_profile,
ApprovalsReviewer::AutoReview,
)
);
+4 -1
View File
@@ -589,7 +589,9 @@ impl App {
let approvals_reviewer =
approvals_reviewer.unwrap_or(config.approvals_reviewer);
let active_permission_profile =
if config.permissions.permission_profile() == permission_profile.clone() {
if config.permissions.effective_permission_profile()
== permission_profile.clone()
{
config.permissions.active_permission_profile()
} else {
None
@@ -603,6 +605,7 @@ impl App {
approvals_reviewer,
permission_profile.clone(),
active_permission_profile,
config.permissions.user_visible_workspace_roots(),
model.to_string(),
*effort,
*summary,
+9 -3
View File
@@ -19,7 +19,9 @@ impl App {
.chat_widget
.config_ref()
.permissions
.permission_profile();
.permission_profile()
.get()
.clone();
let active_permission_profile = self
.chat_widget
.config_ref()
@@ -101,6 +103,8 @@ impl App {
.config_ref()
.permissions
.permission_profile()
.get()
.clone()
}
fn current_active_permission_profile(&self) -> Option<ActivePermissionProfile> {
@@ -350,11 +354,13 @@ mod tests {
.chat_widget
.config_ref()
.permissions
.permission_profile();
.permission_profile()
.get()
.clone();
assert_eq!(session.permission_profile, expected_permission_profile);
assert_ne!(
session.permission_profile,
app.config.permissions.permission_profile(),
app.config.permissions.permission_profile().get().clone(),
"thread/read fallback must use the active widget permissions rather than stale app \
config defaults"
);
+71 -20
View File
@@ -108,7 +108,6 @@ use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
@@ -552,6 +551,7 @@ impl AppServerSession {
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
workspace_roots: &[AbsolutePathBuf],
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
@@ -565,6 +565,7 @@ impl AppServerSession {
&permission_profile,
active_permission_profile,
cwd.as_path(),
workspace_roots,
self.thread_params_mode(),
);
self.client
@@ -1174,15 +1175,14 @@ fn sandbox_mode_from_permission_profile(
fn permissions_selection_from_active_profile(
active: ActivePermissionProfile,
cwd: &std::path::Path,
workspace_roots: &[AbsolutePathBuf],
) -> PermissionProfileSelectionParams {
let modifications = active
.modifications
.into_iter()
.map(|modification| match modification {
ActivePermissionProfileModification::AdditionalWritableRoot { path } => {
PermissionProfileModificationParams::AdditionalWritableRoot { path }
}
})
let modifications = workspace_roots
.iter()
.filter(|root| root.as_path() != cwd)
.cloned()
.map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path })
.collect::<Vec<_>>();
PermissionProfileSelectionParams::Profile {
id: active.id,
@@ -1194,13 +1194,15 @@ fn turn_permissions_overrides(
permission_profile: &PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
cwd: &std::path::Path,
workspace_roots: &[AbsolutePathBuf],
thread_params_mode: ThreadParamsMode,
) -> (
Option<codex_app_server_protocol::SandboxPolicy>,
Option<PermissionProfileSelectionParams>,
) {
let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) {
active_permission_profile.map(permissions_selection_from_active_profile)
active_permission_profile
.map(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots))
} else {
None
};
@@ -1229,7 +1231,13 @@ fn permissions_selection_from_config(
config
.permissions
.active_permission_profile()
.map(permissions_selection_from_active_profile)
.map(|active| {
permissions_selection_from_active_profile(
active,
config.cwd.as_path(),
config.permissions.user_visible_workspace_roots(),
)
})
}
fn thread_start_params_from_config(
@@ -1243,7 +1251,7 @@ fn thread_start_params_from_config(
.is_none()
.then(|| {
sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
)
})
@@ -1277,7 +1285,7 @@ fn thread_resume_params_from_config(
.is_none()
.then(|| {
sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
)
})
@@ -1309,7 +1317,7 @@ fn thread_fork_params_from_config(
.is_none()
.then(|| {
sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
)
})
@@ -1499,7 +1507,7 @@ fn permission_profile_from_thread_response(
return permission_profile.clone().into();
}
match thread_params_mode {
ThreadParamsMode::Embedded => config.permissions.permission_profile(),
ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(),
ThreadParamsMode::Remote => {
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd)
}
@@ -1635,7 +1643,13 @@ mod tests {
config
.permissions
.active_permission_profile()
.map(permissions_selection_from_active_profile)
.map(|active| {
permissions_selection_from_active_profile(
active,
config.cwd.as_path(),
config.permissions.user_visible_workspace_roots(),
)
})
);
assert_eq!(params.model_provider, Some(config.model_provider_id));
assert_eq!(params.thread_source, Some(ThreadSource::User));
@@ -1661,13 +1675,18 @@ mod tests {
let cwd = test_path_buf("/workspace/project").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let expected_permissions =
permissions_selection_from_active_profile(active_permission_profile.clone());
let workspace_roots = vec![cwd.clone()];
let expected_permissions = permissions_selection_from_active_profile(
active_permission_profile.clone(),
cwd.as_path(),
&workspace_roots,
);
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
@@ -1675,6 +1694,36 @@ mod tests {
assert_eq!(permissions, Some(expected_permissions));
}
#[test]
fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() {
let cwd = test_path_buf("/workspace/project").abs();
let extra_root = test_path_buf("/workspace/cache").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let workspace_roots = vec![cwd.clone(), extra_root.clone()];
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
assert_eq!(sandbox_policy, None);
assert_eq!(
permissions,
Some(PermissionProfileSelectionParams::Profile {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
modifications: Some(vec![
PermissionProfileModificationParams::AdditionalWritableRoot {
path: extra_root
}
]),
})
);
}
#[test]
fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() {
let cwd = test_path_buf("/workspace/project").abs();
@@ -1683,6 +1732,7 @@ mod tests {
&PermissionProfile::read_only(),
/*active_permission_profile*/ None,
cwd.as_path(),
std::slice::from_ref(&cwd),
ThreadParamsMode::Embedded,
);
@@ -1705,6 +1755,7 @@ mod tests {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
cwd.as_path(),
std::slice::from_ref(&cwd),
ThreadParamsMode::Remote,
);
@@ -1723,7 +1774,7 @@ mod tests {
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let expected_sandbox = sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
);
@@ -1830,7 +1881,7 @@ mod tests {
let thread_id = ThreadId::new();
let remote_cwd = PathBuf::from("repo/on/server");
let expected_sandbox = sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
);
@@ -334,7 +334,7 @@ impl ChatWidget {
None if self.config.notices.fast_default_opt_out == Some(true) => Some(None),
None => None,
};
let permission_profile = self.config.permissions.permission_profile();
let permission_profile = self.config.permissions.effective_permission_profile();
let op = AppCommand::user_turn(
items,
self.config.cwd.to_path_buf(),
@@ -17,7 +17,7 @@ impl ChatWidget {
let include_read_only = cfg!(target_os = "windows");
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
let current_permission_profile = self.config.permissions.permission_profile();
let current_permission_profile = self.config.permissions.permission_profile().get().clone();
let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval);
let current_review_policy = self.config.approvals_reviewer;
let mut items: Vec<SelectionItem> = Vec::new();
+25 -4
View File
@@ -32,7 +32,26 @@ impl ChatWidget {
self.forked_from = session.forked_from_id;
self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf());
let previous_cwd = self.config.cwd.clone();
let previous_workspace_roots = self.config.workspace_roots.clone();
self.config.cwd = session.cwd.clone();
if !self.config.workspace_roots_explicit {
let mut workspace_roots = vec![session.cwd.clone()];
if previous_workspace_roots
.iter()
.any(|root| root == &previous_cwd)
{
for root in previous_workspace_roots {
if root != previous_cwd
&& !workspace_roots.iter().any(|existing| existing == &root)
{
workspace_roots.push(root);
}
}
}
self.config.workspace_roots = workspace_roots.clone();
self.config.permissions.set_workspace_roots(workspace_roots);
}
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
@@ -53,10 +72,12 @@ impl ChatWidget {
);
if let Err(err) = permission_sync {
tracing::warn!(%err, "failed to sync permissions from SessionConfigured");
self.config.permissions.permission_profile =
Constrained::allow_only(session.permission_profile.clone());
self.config.permissions.active_permission_profile =
session.active_permission_profile.clone();
self.config
.permissions
.set_constrained_permission_profile_with_active_profile(
Constrained::allow_only(session.permission_profile.clone()),
session.active_permission_profile.clone(),
);
}
self.config.approvals_reviewer = session.approvals_reviewer;
self.status_line_project_root_name_cache = None;
@@ -901,8 +901,12 @@ fn permissions_display(config: &Config) -> String {
return active_permission_profile.id.clone();
}
let permission_profile = config.permissions.permission_profile();
let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path());
let permission_profile = config.permissions.effective_permission_profile();
let summary = summarize_permission_profile(
&permission_profile,
&config.cwd,
config.permissions.workspace_roots(),
);
if let Some(details) = summary.strip_prefix("read-only")
&& !details.contains("(network access enabled)")
{
@@ -282,7 +282,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
AppServerPermissionProfile::from(
chat.config_ref().permissions.effective_permission_profile()
),
expected_app_server_permission_profile
);
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
@@ -291,9 +293,65 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
chat.set_permission_profile(updated_profile.clone())
.expect("set permission profile");
assert_eq!(
chat.config_ref().permissions.permission_profile(),
updated_profile,
"local permission changes should replace SessionConfigured profile-derived runtime permissions"
chat.config_ref().permissions.permission_profile().get(),
&updated_profile,
"local permission changes should replace SessionConfigured canonical permissions"
);
assert_eq!(
chat.config_ref().permissions.effective_permission_profile(),
updated_profile
.materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)),
"effective permissions should still use the current thread runtime workspace roots"
);
}
#[tokio::test]
async fn session_configured_preserves_profile_workspace_roots() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let previous_cwd = test_path_buf("/home/user/main").abs();
let profile_root = test_path_buf("/home/user/shared").abs();
chat.config.cwd = previous_cwd.clone();
chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()];
chat.config.workspace_roots_explicit = false;
chat.config
.permissions
.set_workspace_roots(chat.config.workspace_roots.clone());
let session_cwd = test_path_buf("/home/user/sub-agent").abs();
let session_workspace_roots = vec![session_cwd.clone(), profile_root];
let session_permission_profile = PermissionProfile::workspace_write()
.materialize_project_roots_with_workspace_roots(&session_workspace_roots);
let configured = crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: session_permission_profile.clone(),
active_permission_profile: None,
cwd: session_cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_thread_session(configured);
assert_eq!(&chat.config_ref().cwd, &session_cwd);
assert_eq!(
chat.config_ref().permissions.user_visible_workspace_roots(),
session_workspace_roots.as_slice()
);
assert_eq!(
chat.config_ref().permissions.effective_permission_profile(),
session_permission_profile
);
}
@@ -334,7 +392,9 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
AppServerPermissionProfile::from(
chat.config_ref().permissions.effective_permission_profile()
),
expected_app_server_permission_profile
);
}
@@ -15,14 +15,7 @@ impl ChatWidget {
}
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let Ok(policy) = self
.config
.permissions
.permission_profile()
.to_legacy_sandbox_policy(self.config.cwd.as_path())
else {
return Some((Vec::new(), 0, true));
};
let policy = self.config.legacy_sandbox_policy();
match codex_windows_sandbox::apply_world_writable_scan_and_denies(
self.config.codex_home.as_path(),
cwd.as_path(),
@@ -72,7 +65,9 @@ impl ChatWidget {
let mode_label = preset
.as_ref()
.map(|p| describe_profile(&p.permission_profile))
.unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile()));
.unwrap_or_else(|| {
describe_profile(&self.config.permissions.effective_permission_profile())
});
let info_line = if failed_scan {
Line::from(vec![
"We couldn't complete the world-writable scan, so protections cannot be verified. "
+1 -1
View File
@@ -1683,7 +1683,7 @@ pub(crate) fn new_session_info(
pub(crate) fn is_yolo_mode(config: &Config) -> bool {
has_yolo_permissions(
AskForApproval::from(config.permissions.approval_policy.value()),
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
)
}
+1 -1
View File
@@ -1047,7 +1047,7 @@ pub async fn run_main(
if let Some(warning) = add_dir_warning_message(
&cli.add_dir,
&config.permissions.permission_profile(),
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
) {
#[allow(clippy::print_stderr)]
+57 -31
View File
@@ -15,17 +15,16 @@ use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_sandbox_summary::summarize_permission_profile;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use url::Url;
@@ -256,7 +255,8 @@ impl StatusHistoryCell {
refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) {
let approval_policy = AskForApproval::from(config.permissions.approval_policy.value());
let permission_profile = config.permissions.permission_profile();
let permission_profile = config.permissions.effective_permission_profile();
let workspace_roots = config.permissions.user_visible_workspace_roots();
let mut config_entries = vec![
("workdir", config.cwd.display().to_string()),
("model", model_name.to_string()),
@@ -267,7 +267,7 @@ impl StatusHistoryCell {
),
(
"sandbox",
summarize_permission_profile(&permission_profile, config.cwd.as_path()),
summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots),
),
];
if config.model_provider.wire_api == WireApi::Responses {
@@ -291,7 +291,8 @@ impl StatusHistoryCell {
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let active_permission_profile = config.permissions.active_permission_profile();
let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path());
let sandbox = status_permission_summary(&permission_profile, &config.cwd, workspace_roots);
let workspace_root_suffix = workspace_root_suffix(workspace_roots, &config.cwd);
let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval);
let permissions = status_permissions_label(
active_permission_profile.as_ref(),
@@ -299,6 +300,7 @@ impl StatusHistoryCell {
approval_policy,
&sandbox,
&approval,
workspace_root_suffix.as_deref(),
);
let model_provider = format_model_provider(config, runtime_model_provider_base_url);
let account = compose_account_display(account_display);
@@ -542,8 +544,12 @@ impl StatusHistoryCell {
}
}
fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) -> String {
let summary = summarize_permission_profile(permission_profile, cwd);
fn status_permission_summary(
permission_profile: &PermissionProfile,
cwd: &AbsolutePathBuf,
workspace_roots: &[AbsolutePathBuf],
) -> String {
let summary = summarize_permission_profile(permission_profile, cwd, workspace_roots);
if let Some(details) = summary.strip_prefix("read-only") {
if details.contains("(network access enabled)") {
return "read-only with network access".to_string();
@@ -562,33 +568,31 @@ fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path)
summary
}
fn workspace_root_suffix(
workspace_roots: &[AbsolutePathBuf],
cwd: &AbsolutePathBuf,
) -> Option<String> {
let extra_roots = workspace_roots
.iter()
.filter(|root| *root != cwd)
.map(|root| root.to_string_lossy().to_string())
.collect::<Vec<_>>();
if extra_roots.is_empty() {
None
} else {
Some(format!(" [{}]", extra_roots.join(", ")))
}
}
fn status_permissions_label(
active_permission_profile: Option<&ActivePermissionProfile>,
permission_profile: &PermissionProfile,
approval_policy: AskForApproval,
sandbox: &str,
approval: &str,
workspace_root_suffix: Option<&str>,
) -> String {
let active_id = active_permission_profile.map(|active| active.id.as_str());
let writable_root_modifications = active_permission_profile
.map(|active| {
active
.modifications
.iter()
.filter(|modification| {
matches!(
modification,
ActivePermissionProfileModification::AdditionalWritableRoot { .. }
)
})
.count()
})
.unwrap_or(0);
let modification_suffix = match writable_root_modifications {
0 => String::new(),
1 => " + 1 writable root".to_string(),
count => format!(" + {count} writable roots"),
};
match active_id {
Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => {
let label = if sandbox == "read-only with network access" {
@@ -596,12 +600,20 @@ fn status_permissions_label(
} else {
"Read Only"
};
return format!("{label}{modification_suffix} ({approval})");
return format!("{label} ({approval})");
}
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox {
"workspace" => return format!("Workspace{modification_suffix} ({approval})"),
"workspace" => {
return format!(
"Workspace{} ({approval})",
workspace_root_suffix.unwrap_or("")
);
}
"workspace with network access" => {
return format!("Workspace with network access{modification_suffix} ({approval})");
return format!(
"Workspace with network access{} ({approval})",
workspace_root_suffix.unwrap_or("")
);
}
_ => {}
},
@@ -614,7 +626,10 @@ fn status_permissions_label(
format!("No Sandbox ({approval})")
};
}
Some(id) => return format!("Profile {id}{modification_suffix} ({sandbox}, {approval})"),
Some(id) => {
let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix);
return format!("Profile {id} ({sandbox}, {approval})");
}
None => {}
}
@@ -622,16 +637,27 @@ fn status_permissions_label(
return format!("Read Only ({approval})");
}
if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" {
return format!("Workspace ({approval})");
return format!(
"Workspace{} ({approval})",
workspace_root_suffix.unwrap_or("")
);
}
if approval_policy == AskForApproval::Never
&& permission_profile == &PermissionProfile::Disabled
{
return "Full Access".to_string();
}
let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix);
format!("Custom ({sandbox}, {approval})")
}
fn decorate_workspace_sandbox_label(sandbox: &str, workspace_root_suffix: Option<&str>) -> String {
match workspace_root_suffix {
Some(suffix) if sandbox.starts_with("workspace") => format!("{sandbox}{suffix}"),
_ => sandbox.to_string(),
}
}
fn status_approval_label(
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
+62 -35
View File
@@ -31,12 +31,12 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::*;
@@ -97,6 +97,14 @@ async fn test_config(temp_home: &TempDir) -> Config {
config
}
fn set_workspace_cwd(config: &mut Config, cwd: AbsolutePathBuf) {
config.cwd = cwd.clone();
config.workspace_roots = vec![cwd];
config
.permissions
.set_workspace_roots(config.workspace_roots.clone());
}
fn test_status_account_display() -> Option<StatusAccountDisplay> {
None
}
@@ -195,7 +203,7 @@ async fn status_snapshot_includes_reasoning_details() {
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())
@@ -273,7 +281,7 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() {
.approval_policy
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
config
.permissions
.set_permission_profile(app_server_workspace_write_profile(
@@ -332,20 +340,15 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots()
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
Some(
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
.with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
)
.expect("set permission profile");
assert_eq!(
permissions_text_for(&config).as_deref(),
Some("Read Only + 1 writable root (on-request)")
Some("Read Only (on-request)")
);
}
@@ -419,20 +422,44 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() {
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
),
Some(
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
.with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
)
.expect("set permission profile");
assert_eq!(
permissions_text_for(&config).as_deref(),
Some("Workspace + 1 writable root (on-request)")
Some("Workspace (on-request)")
);
}
#[tokio::test]
async fn status_permissions_workspace_roots_show_additional_directories() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
let extra_root = test_path_buf("/workspace/extra").abs();
config.workspace_roots = vec![config.cwd.clone(), extra_root.clone()];
config
.permissions
.set_workspace_roots(config.workspace_roots.clone());
config
.permissions
.set_permission_profile_with_active_profile(
PermissionProfile::workspace_write(),
Some(ActivePermissionProfile::new(":workspace")),
)
.expect("set permission profile");
assert_eq!(
permissions_text_for(&config),
Some(format!("Workspace [{}] (on-request)", extra_root.display()))
);
}
@@ -489,7 +516,7 @@ async fn status_snapshot_shows_active_user_defined_profile() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
config
.permissions
.set_permission_profile_with_active_profile(
@@ -586,7 +613,7 @@ async fn status_snapshot_shows_auto_review_permissions() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
config
.permissions
@@ -689,7 +716,7 @@ async fn status_snapshot_includes_forked_from() {
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -743,7 +770,7 @@ async fn status_snapshot_includes_monthly_limit() {
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1001,7 +1028,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1049,7 +1076,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1113,7 +1140,7 @@ async fn status_snapshot_shows_missing_limits_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1161,7 +1188,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1212,7 +1239,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let usage = TokenUsage {
input_tokens: 500,
@@ -1277,7 +1304,7 @@ async fn status_snapshot_includes_credits_and_limits() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1347,7 +1374,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1405,7 +1432,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let usage = TokenUsage {
input_tokens: 500,
@@ -1463,7 +1490,7 @@ async fn status_snapshot_shows_stale_limits_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
@@ -1530,7 +1557,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex".to_string());
config.cwd = test_path_buf("/workspace/tests").abs();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {