mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
cf143bf71e
commit
e84ee33cc0
+160
-65
@@ -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<()>
|
||||
{
|
||||
|
||||
+18
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user