Add guardian approval MVP (#13692)

## Summary
- add the guardian reviewer flow for `on-request` approvals in command,
patch, sandbox-retry, and managed-network approval paths
- keep guardian behind `features.guardian_approval` instead of exposing
a public `approval_policy = guardian` mode
- route ordinary `OnRequest` approvals to the guardian subagent when the
feature is enabled, without changing the public approval-mode surface

## Public model
- public approval modes stay unchanged
- guardian is enabled via `features.guardian_approval`
- when that feature is on, `approval_policy = on-request` keeps the same
approval boundaries but sends those approval requests to the guardian
reviewer instead of the user
- `/experimental` only persists the feature flag; it does not rewrite
`approval_policy`
- CLI and app-server no longer expose a separate `guardian` approval
mode in this PR

## Guardian reviewer
- the reviewer runs as a normal subagent and reuses the existing
subagent/thread machinery
- it is locked to a read-only sandbox and `approval_policy = never`
- it does not inherit user/project exec-policy rules
- it prefers `gpt-5.4` when the current provider exposes it, otherwise
falls back to the parent turn's active model
- it fail-closes on timeout, startup failure, malformed output, or any
other review error
- it currently auto-approves only when `risk_score < 80`

## Review context and policy
- guardian mirrors `OnRequest` approval semantics rather than
introducing a separate approval policy
- explicit `require_escalated` requests follow the same approval surface
as `OnRequest`; the difference is only who reviews them
- managed-network allowlist misses that enter the approval flow are also
reviewed by guardian
- the review prompt includes bounded recent transcript history plus
recent tool call/result evidence
- transcript entries and planned-action strings are truncated with
explicit `<guardian_truncated ... />` markers so large payloads stay
bounded
- apply-patch reviews include the full patch content (without
duplicating the structured `changes` payload)
- the guardian request layout is snapshot-tested using the same
model-visible Responses request formatter used elsewhere in core

## Guardian network behavior
- the guardian subagent inherits the parent session's managed-network
allowlist when one exists, so it can use the same approved network
surface while reviewing
- exact session-scoped network approvals are copied into the guardian
session with protocol/port scope preserved
- those copied approvals are now seeded before the guardian's first turn
is submitted, so inherited approvals are available during any immediate
review-time checks

## Out of scope / follow-ups
- the sandbox-permission validation split was pulled into a separate PR
and is not part of this diff
- a future follow-up can enable `serde_json` preserve-order in
`codex-core` and then simplify the guardian action rendering further

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charley Cunningham
2026-03-07 05:40:10 -08:00
committed by GitHub
Unverified
parent cf143bf71e
commit e84ee33cc0
34 changed files with 2477 additions and 139 deletions
+160 -65
View File
@@ -793,6 +793,77 @@ impl App {
}
}
async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) {
if updates.is_empty() {
return;
}
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
matches!(
feature,
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
)
});
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in updates {
let feature_key = feature.key();
if let Err(err) = self.config.features.set_enabled(feature, enabled) {
tracing::error!(
error = %err,
feature = feature_key,
"failed to update constrained feature flags"
);
self.chat_widget.add_error_message(format!(
"Failed to update experimental feature `{feature_key}`: {err}"
));
continue;
}
let effective_enabled = self.config.features.enabled(feature);
self.chat_widget
.set_feature_enabled(feature, effective_enabled);
if effective_enabled {
builder = builder.set_feature_enabled(feature_key, true);
} else if feature.default_enabled() {
builder = builder.set_feature_enabled(feature_key, false);
} else {
// If the feature already default to `false`, we drop the key
// in the config file so that the user does not miss the feature
// once it gets globally released.
builder = builder.with_edits(vec![ConfigEdit::ClearPath {
segments: vec!["features".to_string(), feature_key.to_string()],
}]);
}
}
if windows_sandbox_changed {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
self.app_event_tx
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}));
}
}
if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget
.add_error_message(format!("Failed to update experimental features: {err}"));
}
}
fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
@@ -2876,71 +2947,7 @@ impl App {
}
}
AppEvent::UpdateFeatureFlags { updates } => {
if updates.is_empty() {
return Ok(AppRunControl::Continue);
}
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
matches!(
feature,
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
)
});
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in &updates {
let feature_key = feature.key();
if let Err(err) = self.config.features.set_enabled(*feature, *enabled) {
tracing::error!(
error = %err,
feature = feature_key,
"failed to update constrained feature flags"
);
self.chat_widget.add_error_message(format!(
"Failed to update experimental feature `{feature_key}`: {err}"
));
continue;
}
let effective_enabled = self.config.features.enabled(*feature);
self.chat_widget
.set_feature_enabled(*feature, effective_enabled);
if effective_enabled {
builder = builder.set_feature_enabled(feature_key, true);
} else if feature.default_enabled() {
builder = builder.set_feature_enabled(feature_key, false);
} else {
// If the feature already default to `false`, we drop the key
// in the config file so that the user does not miss the feature
// once it gets globally released.
builder = builder.with_edits(vec![ConfigEdit::ClearPath {
segments: vec!["features".to_string(), feature_key.to_string()],
}]);
}
}
if windows_sandbox_changed {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
self.app_event_tx
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}));
}
}
if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget.add_error_message(format!(
"Failed to update experimental features: {err}"
));
}
self.update_feature_flags(updates).await;
}
AppEvent::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
@@ -4874,6 +4881,94 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn update_feature_flags_enabling_guardian_persists_only_the_feature_flag() -> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
let current_session_policy = app
.chat_widget
.config_ref()
.permissions
.approval_policy
.value();
app.update_feature_flags(vec![(Feature::GuardianApproval, true)])
.await;
assert!(app.config.features.enabled(Feature::GuardianApproval));
assert!(
app.chat_widget
.config_ref()
.features
.enabled(Feature::GuardianApproval)
);
assert_eq!(
app.config.permissions.approval_policy.value(),
current_session_policy
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.approval_policy
.value(),
current_session_policy
);
assert_eq!(app.runtime_approval_policy_override, None);
assert!(
op_rx.try_recv().is_err(),
"feature toggle should not patch the active session"
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("guardian_approval = true"));
assert!(!config.contains("approval_policy"));
Ok(())
}
#[tokio::test]
async fn update_feature_flags_disabling_guardian_clears_only_the_feature_flag() -> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\nguardian_approval = true\n",
)?;
app.config
.features
.set_enabled(Feature::GuardianApproval, true)?;
app.chat_widget
.set_feature_enabled(Feature::GuardianApproval, true);
let current_session_policy = app.config.permissions.approval_policy.value();
app.update_feature_flags(vec![(Feature::GuardianApproval, false)])
.await;
assert!(!app.config.features.enabled(Feature::GuardianApproval));
assert!(
!app.chat_widget
.config_ref()
.features
.enabled(Feature::GuardianApproval)
);
assert_eq!(
app.config.permissions.approval_policy.value(),
current_session_policy
);
assert_eq!(app.runtime_approval_policy_override, None);
assert!(
op_rx.try_recv().is_err(),
"feature toggle should not patch the active session"
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("guardian_approval = true"));
assert!(!config.contains("approval_policy"));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
{
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
[ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for interactive website debugging
and other inline JavaScript execution capabilities. Requires Node >= v22.22.0
installed.
[ ] Multi-agents Ask Codex to spawn multiple agents to parallelize the work and win in efficiency.
[ ] Apps Use a connected ChatGPT App using "$". Install Apps via /apps command. Restart
Codex after enabling.
[ ] Guardian approvals Let a guardian subagent review `on-request` approval prompts instead of showing
them to you, including sandbox escapes and blocked network access.
[ ] Prevent sleep while running Keep your computer awake while Codex is running a thread.
Press space to select or enter to save for next conversation
+10
View File
@@ -6942,6 +6942,16 @@ async fn experimental_popup_shows_js_repl_node_requirement() {
);
}
#[tokio::test]
async fn experimental_popup_includes_guardian_approval() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_experimental_popup();
let popup = render_bottom_popup(&chat, 120);
assert_snapshot!("experimental_popup_includes_guardian_approval", popup);
}
#[tokio::test]
async fn multi_agent_enable_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;