mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
co-authored by
Codex
parent
e3cbf913e8
commit
bc24017d64
@@ -310,6 +310,13 @@ impl ExecCommandToolOutput {
|
||||
fn response_text(&self) -> String {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
if let Some(command) = &self.session_command {
|
||||
sections.push(format!(
|
||||
"Command: {}",
|
||||
codex_shell_command::parse_command::shlex_join(command)
|
||||
));
|
||||
}
|
||||
|
||||
if !self.chunk_id.is_empty() {
|
||||
sections.push(format!("Chunk ID: {}", self.chunk_id));
|
||||
}
|
||||
|
||||
@@ -245,7 +245,11 @@ fn exec_command_tool_output_formats_truncated_response() {
|
||||
process_id: None,
|
||||
exit_code: Some(0),
|
||||
original_token_count: Some(10),
|
||||
session_command: None,
|
||||
session_command: Some(vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"rm -rf /tmp/example.sqlite".to_string(),
|
||||
]),
|
||||
}
|
||||
.to_response_item("call-42", &payload);
|
||||
|
||||
@@ -259,7 +263,8 @@ fn exec_command_tool_output_formats_truncated_response() {
|
||||
.expect("exec output should serialize as text");
|
||||
assert_regex_match(
|
||||
r#"(?sx)
|
||||
^Chunk\ ID:\ abc123
|
||||
^Command:\ /bin/zsh\ -lc\ 'rm\ -rf\ /tmp/example\.sqlite'
|
||||
\nChunk\ ID:\ abc123
|
||||
\nWall\ time:\ \d+\.\d{4}\ seconds
|
||||
\nProcess\ exited\ with\ code\ 0
|
||||
\nOriginal\ token\ count:\ 10
|
||||
|
||||
@@ -157,6 +157,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
turn.tools_config.allow_login_shell,
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
|
||||
|
||||
let ExecCommandArgs {
|
||||
workdir,
|
||||
@@ -278,7 +279,9 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("exec_command failed: {err:?}"))
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"exec_command failed for `{command_for_display}`: {err:?}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
"write_stdin" => {
|
||||
|
||||
@@ -161,6 +161,7 @@ impl PendingHostApproval {
|
||||
|
||||
struct ActiveNetworkApprovalCall {
|
||||
registration_id: String,
|
||||
turn_id: String,
|
||||
}
|
||||
|
||||
pub(crate) struct NetworkApprovalService {
|
||||
@@ -190,10 +191,16 @@ impl NetworkApprovalService {
|
||||
other_approved_hosts.extend(approved_hosts.iter().cloned());
|
||||
}
|
||||
|
||||
async fn register_call(&self, registration_id: String) {
|
||||
async fn register_call(&self, registration_id: String, turn_id: String) {
|
||||
let mut active_calls = self.active_calls.lock().await;
|
||||
let key = registration_id.clone();
|
||||
active_calls.insert(key, Arc::new(ActiveNetworkApprovalCall { registration_id }));
|
||||
active_calls.insert(
|
||||
key,
|
||||
Arc::new(ActiveNetworkApprovalCall {
|
||||
registration_id,
|
||||
turn_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) async fn unregister_call(&self, registration_id: &str) {
|
||||
@@ -339,11 +346,18 @@ impl NetworkApprovalService {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
};
|
||||
let owner_call = self.resolve_single_active_call().await;
|
||||
let approval_decision = if routes_approval_to_guardian(&turn_context) {
|
||||
// TODO(ccunningham): Attach guardian network reviews to the reviewed tool item
|
||||
// lifecycle instead of this temporary standalone network approval id.
|
||||
review_approval_request(
|
||||
&session,
|
||||
&turn_context,
|
||||
GuardianApprovalRequest::NetworkAccess {
|
||||
id: Self::approval_id_for_key(&key),
|
||||
turn_id: owner_call
|
||||
.as_ref()
|
||||
.map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),
|
||||
target,
|
||||
host: request.host,
|
||||
protocol,
|
||||
@@ -440,24 +454,31 @@ impl NetworkApprovalService {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
if let Some(owner_call) = owner_call.as_ref() {
|
||||
self.record_call_outcome(
|
||||
&owner_call.registration_id,
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
cache_session_deny = true;
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
if routes_approval_to_guardian(&turn_context) {
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByPolicy(
|
||||
GUARDIAN_REJECTION_MESSAGE.to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
self.record_outcome_for_single_active_call(
|
||||
if let Some(owner_call) = owner_call.as_ref() {
|
||||
self.record_call_outcome(
|
||||
&owner_call.registration_id,
|
||||
NetworkApprovalOutcome::DeniedByPolicy(
|
||||
GUARDIAN_REJECTION_MESSAGE.to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if let Some(owner_call) = owner_call.as_ref() {
|
||||
self.record_call_outcome(
|
||||
&owner_call.registration_id,
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
@@ -523,8 +544,7 @@ pub(crate) fn build_network_policy_decider(
|
||||
|
||||
pub(crate) async fn begin_network_approval(
|
||||
session: &Session,
|
||||
_turn_id: &str,
|
||||
_call_id: &str,
|
||||
turn_id: &str,
|
||||
has_managed_network_requirements: bool,
|
||||
spec: Option<NetworkApprovalSpec>,
|
||||
) -> Option<ActiveNetworkApproval> {
|
||||
@@ -537,7 +557,7 @@ pub(crate) async fn begin_network_approval(
|
||||
session
|
||||
.services
|
||||
.network_approval
|
||||
.register_call(registration_id.clone())
|
||||
.register_call(registration_id.clone(), turn_id.to_string())
|
||||
.await;
|
||||
|
||||
Some(ActiveNetworkApproval {
|
||||
|
||||
@@ -154,7 +154,9 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
|
||||
#[tokio::test]
|
||||
async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
|
||||
let service = NetworkApprovalService::default();
|
||||
service.register_call("registration-1".to_string()).await;
|
||||
service
|
||||
.register_call("registration-1".to_string(), "turn-1".to_string())
|
||||
.await;
|
||||
|
||||
service
|
||||
.record_blocked_request(denied_blocked_request("example.com"))
|
||||
@@ -171,7 +173,9 @@ async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
|
||||
#[tokio::test]
|
||||
async fn blocked_request_policy_does_not_override_user_denial_outcome() {
|
||||
let service = NetworkApprovalService::default();
|
||||
service.register_call("registration-1".to_string()).await;
|
||||
service
|
||||
.register_call("registration-1".to_string(), "turn-1".to_string())
|
||||
.await;
|
||||
|
||||
service
|
||||
.record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser)
|
||||
@@ -189,8 +193,12 @@ async fn blocked_request_policy_does_not_override_user_denial_outcome() {
|
||||
#[tokio::test]
|
||||
async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() {
|
||||
let service = NetworkApprovalService::default();
|
||||
service.register_call("registration-1".to_string()).await;
|
||||
service.register_call("registration-2".to_string()).await;
|
||||
service
|
||||
.register_call("registration-1".to_string(), "turn-1".to_string())
|
||||
.await;
|
||||
service
|
||||
.register_call("registration-2".to_string(), "turn-1".to_string())
|
||||
.await;
|
||||
|
||||
service
|
||||
.record_blocked_request(denied_blocked_request("example.com"))
|
||||
|
||||
@@ -60,7 +60,6 @@ impl ToolOrchestrator {
|
||||
let network_approval = begin_network_approval(
|
||||
&tool_ctx.session,
|
||||
&tool_ctx.turn.sub_id,
|
||||
&tool_ctx.call_id,
|
||||
has_managed_network_requirements,
|
||||
tool.network_approval_spec(req, tool_ctx),
|
||||
)
|
||||
|
||||
@@ -53,8 +53,12 @@ impl ApplyPatchRuntime {
|
||||
Self
|
||||
}
|
||||
|
||||
fn build_guardian_review_request(req: &ApplyPatchRequest) -> GuardianApprovalRequest {
|
||||
fn build_guardian_review_request(
|
||||
req: &ApplyPatchRequest,
|
||||
call_id: &str,
|
||||
) -> GuardianApprovalRequest {
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
id: call_id.to_string(),
|
||||
cwd: req.action.cwd.clone(),
|
||||
files: req.file_paths.clone(),
|
||||
change_count: req.changes.len(),
|
||||
@@ -135,7 +139,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
|
||||
let changes = req.changes.clone();
|
||||
Box::pin(async move {
|
||||
if routes_approval_to_guardian(turn) {
|
||||
let action = ApplyPatchRuntime::build_guardian_review_request(req);
|
||||
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
|
||||
return review_approval_request(session, turn, action, retry_reason).await;
|
||||
}
|
||||
if req.permissions_preapproved && retry_reason.is_none() {
|
||||
|
||||
@@ -28,7 +28,7 @@ fn wants_no_sandbox_approval_granular_respects_sandbox_flag() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_request_includes_full_patch_without_duplicate_changes() {
|
||||
fn guardian_review_request_includes_patch_context() {
|
||||
let path = std::env::temp_dir().join("guardian-apply-patch-test.txt");
|
||||
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
|
||||
let expected_cwd = action.cwd.clone();
|
||||
@@ -55,11 +55,12 @@ fn guardian_review_request_includes_full_patch_without_duplicate_changes() {
|
||||
codex_exe: None,
|
||||
};
|
||||
|
||||
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request);
|
||||
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1");
|
||||
|
||||
assert_eq!(
|
||||
guardian_request,
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
id: "call-1".to_string(),
|
||||
cwd: expected_cwd,
|
||||
files: request.file_paths,
|
||||
change_count: 1usize,
|
||||
|
||||
@@ -157,6 +157,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
session,
|
||||
turn,
|
||||
GuardianApprovalRequest::Shell {
|
||||
id: call_id,
|
||||
command,
|
||||
cwd,
|
||||
sandbox_permissions: req.sandbox_permissions,
|
||||
|
||||
@@ -449,6 +449,7 @@ impl CoreShellActionProvider {
|
||||
&session,
|
||||
&turn,
|
||||
GuardianApprovalRequest::Execve {
|
||||
id: call_id.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
program: program.to_string_lossy().into_owned(),
|
||||
argv: argv.to_vec(),
|
||||
|
||||
@@ -122,6 +122,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
session,
|
||||
turn,
|
||||
GuardianApprovalRequest::ExecCommand {
|
||||
id: call_id,
|
||||
command,
|
||||
cwd,
|
||||
sandbox_permissions: req.sandbox_permissions,
|
||||
|
||||
@@ -462,7 +462,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
create_spawn_agent_tool(&config),
|
||||
create_send_input_tool(),
|
||||
create_resume_agent_tool(),
|
||||
create_wait_tool(),
|
||||
create_exec_wait_tool(),
|
||||
create_close_agent_tool(),
|
||||
] {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
|
||||
Reference in New Issue
Block a user