mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Add managed command checkpoints requiring fresh human approval (#58045)
## Why Managed command policies need to require a human decision independently of automatic review, hook allowances, and saved approvals. ## What changed - Support `decision = "prompt-user"` in managed `requirements.toml` prefix rules, requiring a fresh, one-time human decision for matching commands. - Add experimental `humanCommand` approval requests for clients advertising both `experimentalApi` and `requiredHumanApproval`. Accept only `accept`, `decline`, or `cancel`; reject commands when no capable client is available. - Restrict responses to capable clients that received the request, and replay pending prompts to compatible clients on reconnect. - Enable the capability in the TUI and update protocol schemas and client bindings. ## Testing Add coverage for fresh decisions overriding saved allowances, hook allowances, and automatic review, plus child-thread approval routing and rejection of responses from incompatible or unrelated clients. GitOrigin-RevId: 0b95ff6aab7f8a04b7298c8a4329d1fddf6a603e
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
//! Central approval policy-stage execution and reviewer routing.
|
||||
//! Managed human checkpoints cannot be satisfied by hooks, automated review, or reusable approvals.
|
||||
|
||||
use crate::command_canonicalization::canonicalize_command_for_approval;
|
||||
use crate::exec_policy::prompt_is_rejected_by_policy;
|
||||
@@ -16,6 +17,7 @@ use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
|
||||
use crate::tools::sandboxing::ApprovalRequestReasons;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
@@ -51,6 +53,9 @@ use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApprovalContext {
|
||||
/// Preserve the command's policy result through review and retries. Other actions
|
||||
/// use their existing reviewer routing without an execution policy requirement.
|
||||
pub(crate) exec_requirement: Option<ExecApprovalRequirement>,
|
||||
pub(crate) review_context: GuardianReviewContext,
|
||||
pub(crate) cancellation_token: Option<CancellationToken>,
|
||||
pub(crate) call_id: String,
|
||||
@@ -61,6 +66,15 @@ pub(crate) struct ApprovalContext {
|
||||
pub(crate) network_approval_context: Option<NetworkApprovalContext>,
|
||||
}
|
||||
|
||||
impl ApprovalContext {
|
||||
fn requires_human_approval(&self) -> bool {
|
||||
matches!(
|
||||
self.exec_requirement,
|
||||
Some(ExecApprovalRequirement::NeedsHumanApproval { .. })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
#[serde(tag = "tool", rename_all = "snake_case")]
|
||||
pub(crate) enum ApprovalAction {
|
||||
@@ -494,11 +508,42 @@ impl ApprovalResolution {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
async fn check_approval_requirement(&self, ctx: &ApprovalContext) -> Result<(), ToolError> {
|
||||
if ctx.requires_human_approval() {
|
||||
if let Some(reason) = prompt_is_rejected_by_policy(
|
||||
ctx.review_context.approval_policy,
|
||||
/*prompt_is_rule*/ true,
|
||||
) {
|
||||
return Err(ToolError::Rejected(reason.to_string()));
|
||||
}
|
||||
let config = self.get_config().await;
|
||||
let requirements = config.config_layer_stack.requirements();
|
||||
if ctx.strict_auto_review
|
||||
|| ctx.review_context.turn().strict_auto_review_enabled()
|
||||
|| requirements
|
||||
.approvals_reviewer
|
||||
.can_set(&ApprovalsReviewer::User)
|
||||
.is_err()
|
||||
|| requirements.auto_review_required_for_model(&ctx.review_context.model_info.slug)
|
||||
{
|
||||
return Err(ToolError::Rejected("Managed command policy requires human approval, but this session requires automatic review.".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn request_approval(
|
||||
self: &Arc<Self>,
|
||||
action: ApprovalAction,
|
||||
ctx: ApprovalContext,
|
||||
) -> ApprovalResult {
|
||||
if let Err(err) = self.check_approval_requirement(&ctx).await {
|
||||
return ApprovalResult {
|
||||
result: Err(err),
|
||||
source: None,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
}
|
||||
// Retained authority outside current grants needs a new sandbox approval.
|
||||
// Current grants permit review of the input; they never approve the input itself.
|
||||
let policy = ctx.review_context.turn().approval_policy();
|
||||
@@ -523,10 +568,9 @@ impl Session {
|
||||
_ => ctx.call_id.clone(),
|
||||
};
|
||||
|
||||
// Approval precedence is:
|
||||
// 1. Hooks
|
||||
// 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
|
||||
let resolution = match run_permission_request_hooks(
|
||||
// Hook denials always win. Managed checkpoints require a person even
|
||||
// when a hook allows the action or the session uses automatic review.
|
||||
let mut resolution = match run_permission_request_hooks(
|
||||
self,
|
||||
&ctx.review_context,
|
||||
&permission_request_run_id,
|
||||
@@ -535,18 +579,35 @@ impl Session {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(PermissionRequestDecision::Allow) => ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
Some(PermissionRequestDecision::Allow) if !ctx.requires_human_approval() => {
|
||||
ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
}
|
||||
}
|
||||
Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
|
||||
decision: ReviewDecision::denied(message),
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
None => self.request_reviewer_approval(action, &ctx).await,
|
||||
Some(PermissionRequestDecision::Allow) | None => {
|
||||
self.request_reviewer_approval(action, &ctx).await
|
||||
}
|
||||
};
|
||||
if resolution.decision == ReviewDecision::Approved {
|
||||
if let Err(ToolError::Rejected(reason)) = self.check_approval_requirement(&ctx).await {
|
||||
resolution.decision = ReviewDecision::denied(reason);
|
||||
}
|
||||
if ctx.requires_human_approval()
|
||||
&& ctx
|
||||
.cancellation_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
{
|
||||
resolution.decision = ReviewDecision::Abort;
|
||||
}
|
||||
}
|
||||
// Network approvals record their final telemetry after validation and persistence.
|
||||
if !is_network_approval {
|
||||
record_resolution(&ctx, &resolution);
|
||||
@@ -598,7 +659,9 @@ impl Session {
|
||||
action: ApprovalAction,
|
||||
ctx: &ApprovalContext,
|
||||
) -> ApprovalResolution {
|
||||
if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
|
||||
if ctx.requires_human_approval() {
|
||||
self.request_user_approval(&action, ctx).await
|
||||
} else if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
|
||||
ApprovalResolution {
|
||||
cancellation_reason: (decision == ReviewDecision::Abort)
|
||||
.then_some(McpApprovalCancellationReason::Interrupted),
|
||||
@@ -705,45 +768,68 @@ impl Session {
|
||||
proposed_execpolicy_amendment,
|
||||
..
|
||||
} => {
|
||||
let tool_name = "unified_exec";
|
||||
let reason = ctx
|
||||
.retry_reason
|
||||
.clone()
|
||||
.or_else(|| ctx.approval_reason.clone())
|
||||
.or_else(|| justification.clone());
|
||||
let policy_fingerprint = ctx
|
||||
.review_context
|
||||
.environments()
|
||||
.turn_environments()
|
||||
.find(|environment| environment.selection.environment_id == *environment_id)
|
||||
.and_then(|environment| environment.config().exec_policy.as_ref())
|
||||
.map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
|
||||
let cache_keys = action
|
||||
.cache_keys()
|
||||
.into_iter()
|
||||
.map(|key| (key, &policy_fingerprint))
|
||||
.collect();
|
||||
with_cached_approval(&self.services, tool_name, cache_keys, || async {
|
||||
let human_once = ctx.requires_human_approval();
|
||||
let request = || async {
|
||||
self.request_command_approval(
|
||||
ctx.review_context.turn(),
|
||||
ExecApprovalKind::Command,
|
||||
if human_once {
|
||||
ExecApprovalKind::HumanCommand
|
||||
} else {
|
||||
ExecApprovalKind::Command
|
||||
},
|
||||
ctx.review_context.model_context(),
|
||||
ctx.call_id.clone(),
|
||||
Some(&ctx.call_id),
|
||||
/*approval_id*/ None,
|
||||
human_once.then(|| uuid::Uuid::new_v4().to_string()),
|
||||
Some(environment_id.clone()),
|
||||
command.clone(),
|
||||
cwd.clone(),
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
proposed_execpolicy_amendment.clone(),
|
||||
if human_once {
|
||||
None
|
||||
} else {
|
||||
ctx.network_approval_context.clone()
|
||||
},
|
||||
if human_once {
|
||||
None
|
||||
} else {
|
||||
proposed_execpolicy_amendment.clone()
|
||||
},
|
||||
additional_permissions.clone(),
|
||||
/*available_decisions*/ None,
|
||||
human_once.then(|| {
|
||||
vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::denied("rejected by user"),
|
||||
ReviewDecision::Abort,
|
||||
]
|
||||
}),
|
||||
/*plugin_attribution_override*/ None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
};
|
||||
if human_once {
|
||||
request().await
|
||||
} else {
|
||||
let tool_name = "unified_exec";
|
||||
let policy_fingerprint = ctx
|
||||
.review_context
|
||||
.environments()
|
||||
.turn_environments()
|
||||
.find(|environment| environment.selection.environment_id == *environment_id)
|
||||
.and_then(|environment| environment.config().exec_policy.as_ref())
|
||||
.map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
|
||||
let cache_keys = action
|
||||
.cache_keys()
|
||||
.into_iter()
|
||||
.map(|key| (key, &policy_fingerprint))
|
||||
.collect();
|
||||
with_cached_approval(&self.services, tool_name, cache_keys, request).await
|
||||
}
|
||||
}
|
||||
ApprovalAction::WriteStdin {
|
||||
id,
|
||||
|
||||
@@ -128,6 +128,7 @@ async fn non_utf8_cwd_preserves_approval_routing(
|
||||
review_context.approval_policy = AskForApproval::OnRequest;
|
||||
review_context.approvals_reviewer = reviewer;
|
||||
let context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: "non-utf8-cwd".to_string(),
|
||||
@@ -213,6 +214,7 @@ async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() {
|
||||
review_context.approval_policy = AskForApproval::OnRequest;
|
||||
review_context.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
let context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: "mcp-override".to_string(),
|
||||
|
||||
@@ -799,6 +799,7 @@ impl NetworkApprovalService {
|
||||
cwd,
|
||||
};
|
||||
let approval_context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: approval_call_id,
|
||||
|
||||
@@ -164,6 +164,10 @@ impl ToolOrchestrator {
|
||||
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
|
||||
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
|
||||
});
|
||||
let human_once = matches!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsHumanApproval { .. }
|
||||
);
|
||||
match &requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => {
|
||||
if strict_auto_review {
|
||||
@@ -173,6 +177,7 @@ impl ToolOrchestrator {
|
||||
ToolError::Rejected(format!("could not prepare approval action: {err}"))
|
||||
})?;
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -200,13 +205,15 @@ impl ToolOrchestrator {
|
||||
ExecApprovalRequirement::Forbidden { reason } => {
|
||||
return Err(ToolError::Rejected(reason.clone()));
|
||||
}
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. }
|
||||
| ExecApprovalRequirement::NeedsHumanApproval { reason } => {
|
||||
let action = tool
|
||||
.approval_action(req, &tool_ctx.call_id)
|
||||
.map_err(|err| {
|
||||
ToolError::Rejected(format!("could not prepare approval action: {err}"))
|
||||
})?;
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -483,16 +490,19 @@ impl ToolOrchestrator {
|
||||
|
||||
// Human and policy approvals keep their existing retry scope. A
|
||||
// Guardian verdict only covers the authority of the reviewed action.
|
||||
let retry_preapproved = matches!(
|
||||
initial_approval_source,
|
||||
Some(ApprovalResolutionSource::User | ApprovalResolutionSource::Hook)
|
||||
);
|
||||
let bypass_retry_approval = !strict_auto_review
|
||||
let retry_preapproved = !human_once
|
||||
&& matches!(
|
||||
initial_approval_source,
|
||||
Some(ApprovalResolutionSource::User | ApprovalResolutionSource::Hook)
|
||||
);
|
||||
let bypass_retry_approval = !human_once
|
||||
&& !strict_auto_review
|
||||
&& tool.should_bypass_approval(approval_policy, retry_preapproved)
|
||||
&& network_approval_context.is_none();
|
||||
if !bypass_retry_approval {
|
||||
let approval_reason = match &requirement {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. }
|
||||
| ExecApprovalRequirement::NeedsHumanApproval { reason } => reason.clone(),
|
||||
ExecApprovalRequirement::Skip { .. }
|
||||
| ExecApprovalRequirement::Forbidden { .. } => None,
|
||||
};
|
||||
@@ -516,6 +526,7 @@ impl ToolOrchestrator {
|
||||
*additional_permissions = None;
|
||||
}
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -526,6 +537,15 @@ impl ToolOrchestrator {
|
||||
network_approval_context: network_approval_context.clone(),
|
||||
};
|
||||
|
||||
if human_once && network_approval_context.is_some() {
|
||||
let mut network_ctx = approval_ctx.clone();
|
||||
network_ctx.exec_requirement = None;
|
||||
tool_ctx
|
||||
.session
|
||||
.request_approval(action.clone(), network_ctx)
|
||||
.await
|
||||
.result?;
|
||||
}
|
||||
tool_ctx
|
||||
.session
|
||||
.request_approval(action, approval_ctx)
|
||||
|
||||
@@ -170,6 +170,8 @@ pub(crate) enum ExecApprovalRequirement {
|
||||
/// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined.
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
/// A fresh human decision is required for each execution attempt.
|
||||
NeedsHumanApproval { reason: Option<String> },
|
||||
/// Execution forbidden for this tool call.
|
||||
Forbidden { reason: String },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user