Add safer TUI defaults for cyber models (#37055)

## What changed

- Propagate the optional `modelSpecialty` field through the model catalog and
  app-server `model/list` response.
- When a newly selected model has the `cyber` specialty, default an active TUI
  thread to workspace-write permissions with on-request approval. Use auto
  review when available, otherwise keep the user as reviewer, while respecting
  configured permission requirements.
- Show a notice when auto review is applied and strengthen the full-access
  warning for cyber models.
- Preserve explicitly selected permissions when only reasoning settings change.

## Testing

- Cover auto-review defaults, requirement-aware fallback behavior, permission
  preservation across reasoning changes, and the new warning notice.

GitOrigin-RevId: 8f25b0082d3051d9ff91f7355921236d0f82f758
This commit is contained in:
Won Park authored and copyberry committed 2026-08-05 08:04:15 +00:00
1 parent 3171166881
commit 757c151a0e
35 files changed
+536 -39

No files matched your search

@@ -407,19 +407,45 @@ impl ChatWidget {
) {
let selected_name = preset.label.to_string();
let approval = AskForApproval::from(preset.approval);
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let is_cyber_model = self.model_catalog.try_list_models().is_ok_and(|models| {
models.iter().any(|model| {
model.model == self.current_model()
&& model.model_specialty.as_deref() == Some("cyber")
})
});
let title_line = Line::from("Enable full access?").bold();
let info_line = Line::from(vec![
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
.into(),
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
.fg(Color::Red),
]);
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
let header = ColumnRenderable::with(header_children);
let info_lines = if is_cyber_model {
let recommendation = if auto_review_available(&self.config) {
"We strongly recommend selecting \"Approve for me\" instead, and customizing the reviewer policy for your use case."
} else {
"We strongly recommend selecting \"Ask for approval\" instead."
};
vec![
Line::default(),
Line::from(
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval.",
),
Line::default(),
Line::from(vec![
"Cyber models carry a higher risk of dangerous actions.".red(),
" Ensure proper safeguards are in place before granting full access. ".into(),
recommendation.into(),
]),
]
} else {
vec![Line::from(vec![
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
.into(),
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
.red(),
])]
};
let header = Paragraph::new(
std::iter::once(title_line)
.chain(info_lines)
.collect::<Vec<_>>(),
)
.wrap(Wrap { trim: false });
let accept_actions = profile_selection.map_or_else(
|| {
@@ -1,6 +1,37 @@
use super::*;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
pub(crate) fn auto_review_available(config: &Config) -> bool {
cyber_model_approval_reviewer(config) == Some(ApprovalsReviewer::AutoReview)
}
pub(crate) fn cyber_model_approval_reviewer(config: &Config) -> Option<ApprovalsReviewer> {
let requirements = config.config_layer_stack.requirements();
if requirements
.approval_policy
.can_set(&AskForApproval::OnRequest.to_core())
.is_err()
{
return None;
}
let reviewer = if config.features.enabled(Feature::GuardianApproval)
&& requirements
.approvals_reviewer
.can_set(&ApprovalsReviewer::AutoReview)
.is_ok()
{
ApprovalsReviewer::AutoReview
} else {
ApprovalsReviewer::User
};
requirements
.approvals_reviewer
.can_set(&reviewer)
.is_ok()
.then_some(reviewer)
}
impl ChatWidget {
pub(super) fn open_permission_profiles_popup(&mut self) {
let active_profile_id = self
@@ -3141,6 +3141,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
model: slug.to_string(),
display_name: slug.to_string(),
description: format!("{slug} description"),
model_specialty: None,
default_reasoning_effort: ReasoningEffortConfig::Medium,
supported_reasoning_efforts: vec![ReasoningEffortPreset {
effort: ReasoningEffortConfig::Medium,
@@ -3635,6 +3636,7 @@ async fn single_reasoning_option_skips_selection() {
model: "model-with-single-reasoning".to_string(),
display_name: "model-with-single-reasoning".to_string(),
description: "".to_string(),
model_specialty: None,
default_reasoning_effort: ReasoningEffortConfig::High,
supported_reasoning_efforts: single_effort,
supports_personality: false,
@@ -106,8 +106,7 @@ impl ChatWidget {
Line::from(vec![
"We couldn't complete the world-writable scan, so protections cannot be verified. "
.into(),
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
.fg(Color::Red),
format!("The Windows sandbox cannot guarantee protection in {mode_label}.").red(),
])
} else {
Line::from(vec![