Add Smart Approvals guardian review across core, app-server, and TUI (#13860)

## Summary
- add `approvals_reviewer = "user" | "guardian_subagent"` as the runtime
control for who reviews approval requests
- route Smart Approvals guardian review through core for command
execution, file changes, managed-network approvals, MCP approvals, and
delegated/subagent approval flows
- expose guardian review in app-server with temporary unstable
`item/autoApprovalReview/{started,completed}` notifications carrying
`targetItemId`, `review`, and `action`
- update the TUI so Smart Approvals can be enabled from `/experimental`,
aligned with the matching `/approvals` mode, and surfaced clearly while
reviews are pending or resolved

## Runtime model
This PR does not introduce a new `approval_policy`.

Instead:
- `approval_policy` still controls when approval is needed
- `approvals_reviewer` controls who reviewable approval requests are
routed to:
  - `user`
  - `guardian_subagent`

`guardian_subagent` is a carefully prompted reviewer subagent that
gathers relevant context and applies a risk-based decision framework
before approving or denying the request.

The `smart_approvals` feature flag is a rollout/UI gate. Core runtime
behavior keys off `approvals_reviewer`.

When Smart Approvals is enabled from the TUI, it also switches the
current `/approvals` settings to the matching Smart Approvals mode so
users immediately see guardian review in the active thread:
- `approval_policy = on-request`
- `approvals_reviewer = guardian_subagent`
- `sandbox_mode = workspace-write`

Users can still change `/approvals` afterward.

Config-load behavior stays intentionally narrow:
- plain `smart_approvals = true` in `config.toml` remains just the
rollout/UI gate and does not auto-set `approvals_reviewer`
- the deprecated `guardian_approval = true` alias migration does
backfill `approvals_reviewer = "guardian_subagent"` in the same scope
when that reviewer is not already configured there, so old configs
preserve their original guardian-enabled behavior

ARC remains a separate safety check. For MCP tool approvals, ARC
escalations now flow into the configured reviewer instead of always
bypassing guardian and forcing manual review.

## Config stability
The runtime reviewer override is stable, but the config-backed
app-server protocol shape is still settling.

- `thread/start`, `thread/resume`, and `turn/start` keep stable
`approvalsReviewer` overrides
- the config-backed `approvals_reviewer` exposure returned via
`config/read` (including profile-level config) is now marked
`[UNSTABLE]` / experimental in the app-server protocol until we are more
confident in that config surface

## App-server surface
This PR intentionally keeps the guardian app-server shape narrow and
temporary.

It adds generic unstable lifecycle notifications:
- `item/autoApprovalReview/started`
- `item/autoApprovalReview/completed`

with payloads of the form:
- `{ threadId, turnId, targetItemId, review, action? }`

`review` is currently:
- `{ status, riskScore?, riskLevel?, rationale? }`
- where `status` is one of `inProgress`, `approved`, `denied`, or
`aborted`

`action` carries the guardian action summary payload from core when
available. This lets clients render temporary standalone pending-review
UI, including parallel reviews, even when the underlying tool item has
not been emitted yet.

These notifications are explicitly documented as `[UNSTABLE]` and
expected to change soon.

This PR does **not** persist guardian review state onto `thread/read`
tool items. The intended follow-up is to attach guardian review state to
the reviewed tool item lifecycle instead, which would improve
consistency with manual approvals and allow thread history / reconnect
flows to replay guardian review state directly.

## TUI behavior
- `/experimental` exposes the rollout gate as `Smart Approvals`
- enabling it in the TUI enables the feature and switches the current
session to the matching Smart Approvals `/approvals` mode
- disabling it in the TUI clears the persisted `approvals_reviewer`
override when appropriate and returns the session to default manual
review when the effective reviewer changes
- `/approvals` still exposes the reviewer choice directly
- the TUI renders:
- pending guardian review state in the live status footer, including
parallel review aggregation
  - resolved approval/denial state in history

## Scope notes
This PR includes the supporting core/runtime work needed to make Smart
Approvals usable end-to-end:
- shell / unified-exec / apply_patch / managed-network / MCP guardian
review
- delegated/subagent approval routing into guardian review
- guardian review risk metadata and action summaries for app-server/TUI
- config/profile/TUI handling for `smart_approvals`, `guardian_approval`
alias migration, and `approvals_reviewer`
- a small internal cleanup of delegated approval forwarding to dedupe
fallback paths and simplify guardian-vs-parent approval waiting (no
intended behavior change)

Out of scope for this PR:
- redesigning the existing manual approval protocol shapes
- persisting guardian review state onto app-server `ThreadItem`s
- delegated MCP elicitation auto-review (the current delegated MCP
guardian shim only covers the legacy `RequestUserInput` path)

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charley Cunningham
2026-03-13 15:27:00 -07:00
committed by GitHub
parent e3cbf913e8
commit bc24017d64
106 changed files with 5525 additions and 364 deletions

View File

@@ -8,8 +8,10 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RequestUserInputEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::Submission;
@@ -20,8 +22,11 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
@@ -34,6 +39,15 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::error::CodexErr;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request_with_cancel;
use crate::guardian::routes_approval_to_guardian;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
use crate::mcp_tool_call::is_mcp_tool_approval_question_id;
use crate::mcp_tool_call::lookup_mcp_tool_metadata;
use crate::models_manager::manager::ModelsManager;
use codex_protocol::protocol::InitialHistory;
@@ -88,12 +102,17 @@ pub(crate) async fn run_codex_thread_interactive(
let parent_session_clone = Arc::clone(&parent_session);
let parent_ctx_clone = Arc::clone(&parent_ctx);
let codex_for_events = Arc::clone(&codex);
// Cache delegated MCP invocations so guardian can recover the full tool call
// context when the later legacy RequestUserInput approval event only carries
// a call_id plus approval question metadata.
let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::<String, McpInvocation>::new()));
tokio::spawn(async move {
forward_events(
codex_for_events,
tx_sub,
parent_session_clone,
parent_ctx_clone,
pending_mcp_invocations,
cancel_token_events,
)
.await;
@@ -200,6 +219,7 @@ async fn forward_events(
tx_sub: Sender<Event>,
parent_session: Arc<Session>,
parent_ctx: Arc<TurnContext>,
pending_mcp_invocations: Arc<Mutex<HashMap<String, McpInvocation>>>,
cancel_token: CancellationToken,
) {
let cancelled = cancel_token.cancelled();
@@ -285,18 +305,57 @@ async fn forward_events(
id,
&parent_session,
&parent_ctx,
&pending_mcp_invocations,
event,
&cancel_token,
)
.await;
}
Event {
id,
msg: EventMsg::McpToolCallBegin(event),
} => {
pending_mcp_invocations
.lock()
.await
.insert(event.call_id.clone(), event.invocation.clone());
if !forward_event_or_shutdown(
&codex,
&tx_sub,
&cancel_token,
Event {
id,
msg: EventMsg::McpToolCallBegin(event),
},
)
.await
{
break;
}
}
Event {
id,
msg: EventMsg::McpToolCallEnd(event),
} => {
pending_mcp_invocations.lock().await.remove(&event.call_id);
if !forward_event_or_shutdown(
&codex,
&tx_sub,
&cancel_token,
Event {
id,
msg: EventMsg::McpToolCallEnd(event),
},
)
.await
{
break;
}
}
other => {
match tx_sub.send(other).or_cancel(&cancel_token).await {
Ok(Ok(())) => {}
_ => {
shutdown_delegate(&codex).await;
break;
}
if !forward_event_or_shutdown(&codex, &tx_sub, &cancel_token, other).await
{
break;
}
}
}
@@ -323,6 +382,21 @@ async fn shutdown_delegate(codex: &Codex) {
.await;
}
async fn forward_event_or_shutdown(
codex: &Codex,
tx_sub: &Sender<Event>,
cancel_token: &CancellationToken,
event: Event,
) -> bool {
match tx_sub.send(event).or_cancel(cancel_token).await {
Ok(Ok(())) => true,
_ => {
shutdown_delegate(codex).await;
false
}
}
}
/// Forward ops from a caller to a sub-agent, respecting cancellation.
async fn forward_ops(
codex: Arc<Codex>,
@@ -342,8 +416,8 @@ async fn forward_ops(
async fn handle_exec_approval(
codex: &Codex,
turn_id: String,
parent_session: &Session,
parent_ctx: &TurnContext,
parent_session: &Arc<Session>,
parent_ctx: &Arc<TurnContext>,
event: ExecApprovalRequestEvent,
cancel_token: &CancellationToken,
) {
@@ -361,27 +435,56 @@ async fn handle_exec_approval(
available_decisions,
..
} = event;
// Race approval with cancellation and timeout to avoid hangs.
let approval_fut = parent_session.request_command_approval(
parent_ctx,
call_id,
approval_id,
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
skill_metadata,
available_decisions,
);
let decision = await_approval_with_cancel(
approval_fut,
parent_session,
&approval_id_for_op,
cancel_token,
)
.await;
let decision = if routes_approval_to_guardian(parent_ctx) {
let review_cancel = cancel_token.child_token();
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
GuardianApprovalRequest::Shell {
id: call_id.clone(),
command,
cwd,
sandbox_permissions: if additional_permissions.is_some() {
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions
} else {
crate::sandboxing::SandboxPermissions::UseDefault
},
additional_permissions,
justification: None,
},
reason,
review_cancel.clone(),
);
await_approval_with_cancel(
async move { review_rx.await.unwrap_or_default() },
parent_session,
&approval_id_for_op,
cancel_token,
Some(&review_cancel),
)
.await
} else {
await_approval_with_cancel(
parent_session.request_command_approval(
parent_ctx,
call_id,
approval_id,
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
skill_metadata,
available_decisions,
),
parent_session,
&approval_id_for_op,
cancel_token,
None,
)
.await
};
let _ = codex
.submit(Op::ExecApproval {
@@ -396,8 +499,8 @@ async fn handle_exec_approval(
async fn handle_patch_approval(
codex: &Codex,
_id: String,
parent_session: &Session,
parent_ctx: &TurnContext,
parent_session: &Arc<Session>,
parent_ctx: &Arc<TurnContext>,
event: ApplyPatchApprovalRequestEvent,
cancel_token: &CancellationToken,
) {
@@ -409,16 +512,85 @@ async fn handle_patch_approval(
..
} = event;
let approval_id = call_id.clone();
let decision_rx = parent_session
.request_patch_approval(parent_ctx, call_id, changes, reason, grant_root)
.await;
let decision = await_approval_with_cancel(
async move { decision_rx.await.unwrap_or_default() },
parent_session,
&approval_id,
cancel_token,
)
.await;
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
let change_count = changes.len();
let maybe_files = changes
.keys()
.map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok())
.collect::<Option<Vec<_>>>();
if let Some(files) = maybe_files {
let review_cancel = cancel_token.child_token();
let patch = changes
.iter()
.map(|(path, change)| match change {
codex_protocol::protocol::FileChange::Add { content } => {
format!("*** Add File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Delete { content } => {
format!("*** Delete File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
} => {
if let Some(move_path) = move_path {
format!(
"*** Update File: {}\n*** Move to: {}\n{}",
path.display(),
move_path.display(),
unified_diff
)
} else {
format!("*** Update File: {}\n{}", path.display(), unified_diff)
}
}
})
.collect::<Vec<_>>()
.join("\n");
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.clone(),
files,
change_count,
patch,
},
reason.clone(),
review_cancel.clone(),
);
Some(
await_approval_with_cancel(
async move { review_rx.await.unwrap_or_default() },
parent_session,
&approval_id,
cancel_token,
Some(&review_cancel),
)
.await,
)
} else {
None
}
} else {
None
};
let decision = if let Some(decision) = guardian_decision {
decision
} else {
let decision_rx = parent_session
.request_patch_approval(parent_ctx, call_id, changes, reason, grant_root)
.await;
await_approval_with_cancel(
async move { decision_rx.await.unwrap_or_default() },
parent_session,
&approval_id,
cancel_token,
None,
)
.await
};
let _ = codex
.submit(Op::PatchApproval {
id: approval_id,
@@ -430,11 +602,26 @@ async fn handle_patch_approval(
async fn handle_request_user_input(
codex: &Codex,
id: String,
parent_session: &Session,
parent_ctx: &TurnContext,
parent_session: &Arc<Session>,
parent_ctx: &Arc<TurnContext>,
pending_mcp_invocations: &Arc<Mutex<HashMap<String, McpInvocation>>>,
event: RequestUserInputEvent,
cancel_token: &CancellationToken,
) {
if routes_approval_to_guardian(parent_ctx)
&& let Some(response) = maybe_auto_review_mcp_request_user_input(
parent_session,
parent_ctx,
pending_mcp_invocations,
&event,
cancel_token,
)
.await
{
let _ = codex.submit(Op::UserInputAnswer { id, response }).await;
return;
}
let args = RequestUserInputArgs {
questions: event.questions,
};
@@ -450,10 +637,115 @@ async fn handle_request_user_input(
let _ = codex.submit(Op::UserInputAnswer { id, response }).await;
}
/// Intercepts delegated legacy MCP approval prompts on the RequestUserInput
/// compatibility path and, when guardian is active, answers them
/// programmatically after running the guardian review.
///
/// The RequestUserInput event only carries `call_id` plus approval question
/// metadata, so this helper joins it back to the cached `McpToolCallBegin`
/// invocation in order to rebuild the full guardian review request.
async fn maybe_auto_review_mcp_request_user_input(
parent_session: &Arc<Session>,
parent_ctx: &Arc<TurnContext>,
pending_mcp_invocations: &Arc<Mutex<HashMap<String, McpInvocation>>>,
event: &RequestUserInputEvent,
cancel_token: &CancellationToken,
) -> Option<RequestUserInputResponse> {
// TODO(ccunningham): Support delegated MCP approval elicitations here too after
// coordinating with @fouad. Today guardian only auto-reviews the RequestUserInput
// compatibility path for delegated MCP approvals.
let question = event
.questions
.iter()
.find(|question| is_mcp_tool_approval_question_id(&question.id))?;
let invocation = pending_mcp_invocations
.lock()
.await
.get(&event.call_id)
.cloned()?;
let metadata = lookup_mcp_tool_metadata(
parent_session.as_ref(),
parent_ctx.as_ref(),
&invocation.server,
&invocation.tool,
)
.await;
let review_cancel = cancel_token.child_token();
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()),
None,
review_cancel.clone(),
);
let decision = await_approval_with_cancel(
async move { review_rx.await.unwrap_or_default() },
parent_session,
&event.call_id,
cancel_token,
Some(&review_cancel),
)
.await;
let selected_label = match decision {
ReviewDecision::ApprovedForSession => question
.options
.as_ref()
.and_then(|options| {
options
.iter()
.find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
})
.map(|option| option.label.clone())
.unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()),
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(),
ReviewDecision::Denied | ReviewDecision::Abort => {
MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()
}
};
Some(RequestUserInputResponse {
answers: HashMap::from([(
question.id.clone(),
codex_protocol::request_user_input::RequestUserInputAnswer {
answers: vec![selected_label],
},
)]),
})
}
fn spawn_guardian_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
cancel_token: CancellationToken,
) -> oneshot::Receiver<ReviewDecision> {
let (tx, rx) = oneshot::channel();
std::thread::spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
let _ = tx.send(ReviewDecision::Denied);
return;
};
let decision = runtime.block_on(review_approval_request_with_cancel(
&session,
&turn,
request,
retry_reason,
cancel_token,
));
let _ = tx.send(decision);
});
rx
}
async fn handle_request_permissions(
codex: &Codex,
parent_session: &Session,
parent_ctx: &TurnContext,
parent_session: &Arc<Session>,
parent_ctx: &Arc<TurnContext>,
event: RequestPermissionsEvent,
cancel_token: &CancellationToken,
) {
@@ -534,6 +826,7 @@ async fn await_approval_with_cancel<F>(
parent_session: &Session,
approval_id: &str,
cancel_token: &CancellationToken,
review_cancel_token: Option<&CancellationToken>,
) -> codex_protocol::protocol::ReviewDecision
where
F: core::future::Future<Output = codex_protocol::protocol::ReviewDecision>,
@@ -541,6 +834,9 @@ where
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
if let Some(review_cancel_token) = review_cancel_token {
review_cancel_token.cancel();
}
parent_session
.notify_approval(approval_id, codex_protocol::protocol::ReviewDecision::Abort)
.await;