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:
jif
2026-10-29 11:23:51 +00:00
committed by copyberry
parent 058e99a3aa
commit 9cc14e7988
70 changed files with 976 additions and 83 deletions
+118 -32
View File
@@ -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,
+27 -7
View File
@@ -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)
+2
View File
@@ -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 },
}