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
+18 -6
View File
@@ -406,9 +406,9 @@ impl ExecPolicyManager {
),
),
},
Decision::Prompt => {
Decision::Prompt | Decision::PromptUser => {
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt
is_policy_match(rule_match) && rule_match.decision() == evaluation.decision
});
match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
Some(reason) if prompt_is_rule => ExecApprovalRequirement::Forbidden {
@@ -425,6 +425,11 @@ impl ExecPolicyManager {
),
),
},
None if evaluation.decision == Decision::PromptUser => {
ExecApprovalRequirement::NeedsHumanApproval {
reason: derive_prompt_reason(command, &evaluation),
}
}
None => ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
@@ -1048,20 +1053,27 @@ fn derive_prompt_reason(command_args: &[String], evaluation: &Evaluation) -> Opt
.filter_map(|rule_match| match rule_match {
RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: Decision::Prompt,
decision,
justification,
..
} => Some((matched_prefix.len(), justification.as_deref())),
} if *decision == evaluation.decision => {
Some((matched_prefix.len(), justification.as_deref()))
}
_ => None,
})
.max_by_key(|(matched_prefix_len, _)| *matched_prefix_len);
let approval = if evaluation.decision == Decision::PromptUser {
"human approval"
} else {
"approval"
};
match most_specific_prompt {
Some((_matched_prefix_len, Some(justification))) => {
Some(format!("`{command}` requires approval: {justification}"))
Some(format!("`{command}` requires {approval}: {justification}"))
}
Some((_matched_prefix_len, None)) => {
Some(format!("`{command}` requires approval by policy"))
Some(format!("`{command}` requires {approval} by policy"))
}
None => None,
}
+1
View File
@@ -2554,6 +2554,7 @@ async fn windows_powershell_policy_classifies_only_independent_literal_commands(
let actual = match requirement {
ExecApprovalRequirement::Skip { .. } => Decision::Allow,
ExecApprovalRequirement::NeedsApproval { .. } => Decision::Prompt,
ExecApprovalRequirement::NeedsHumanApproval { .. } => Decision::PromptUser,
ExecApprovalRequirement::Forbidden { .. } => Decision::Forbidden,
};
assert_eq!(
@@ -58,6 +58,11 @@ pub(crate) async fn review_tool_call(
let approval = match &requirement {
ExecApprovalRequirement::Skip { .. } => return Ok(None),
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
ExecApprovalRequirement::NeedsHumanApproval { .. } => {
return Err(CodexErr::InvalidRequest(
"Managed command policy requires human approval.".into(),
));
}
ExecApprovalRequirement::Forbidden { reason } => {
return Err(CodexErr::InvalidRequest(reason.clone()));
}
+1
View File
@@ -1604,6 +1604,7 @@ async fn maybe_request_mcp_tool_approval(
allow_persistent_approval: persistent_approval_key.is_some(),
};
let approval_context = ApprovalContext {
exec_requirement: None,
review_context: GuardianReviewContext::from(step_context),
cancellation_token: Some(cancellation_token.clone()),
call_id: call_id.to_string(),
+32
View File
@@ -177,12 +177,44 @@ pub async fn resolve_elicitation(
/// Propagate a user's exec approval decision to the session.
/// Also optionally applies an execpolicy amendment.
#[expect(
clippy::await_holding_invalid_type,
reason = "bind a human decision to its active turn before resolving it"
)]
pub async fn exec_approval(
sess: &Arc<Session>,
approval_id: String,
turn_id: Option<String>,
decision: ReviewDecision,
) {
let human_approval = {
let active = sess.active_turn.lock().await;
let Some(turn) = active.as_ref() else {
return;
};
let mut state = turn.turn_state.lock().await;
let Some(pending) = state.pending_approval(&approval_id) else {
return;
};
if pending.kind == codex_protocol::approvals::ExecApprovalKind::HumanCommand {
state.remove_pending_approval(&approval_id)
} else {
None
}
};
if let Some(pending) = human_approval {
let decision = pending.validate(decision);
if decision == ReviewDecision::Abort {
sess.abort_turn_if_active(
&pending.turn_id,
TurnAbortReason::Interrupted,
/*error*/ None,
)
.await;
}
pending.sender.send(decision).ok();
return;
}
let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
if let ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
+16 -4
View File
@@ -3107,7 +3107,12 @@ impl Session {
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
ts.insert_pending_approval(
effective_approval_id.clone(),
tx_approve,
kind,
turn_context.sub_id.clone(),
)
}
None => None,
}
@@ -3193,7 +3198,12 @@ impl Session {
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_approval(approval_id.clone(), tx_approve)
ts.insert_pending_approval(
approval_id.clone(),
tx_approve,
ExecApprovalKind::Command,
turn_context.sub_id.clone(),
)
}
None => None,
}
@@ -3270,6 +3280,7 @@ impl Session {
let context = sandbox_context.policy_context();
{
let approval_context = ApprovalContext {
exec_requirement: None,
review_context: crate::guardian::GuardianReviewContext::from(step_context),
cancellation_token: Some(cancellation_token.clone()),
call_id: call_id.clone(),
@@ -3623,8 +3634,9 @@ impl Session {
}
};
match entry {
Some(tx_approve) => {
tx_approve.send(decision).ok();
Some(pending) => {
let decision = pending.validate(decision);
pending.sender.send(decision).ok();
}
None => {
warn!("No pending approval found for call_id: {approval_id}");
+38 -7
View File
@@ -88,7 +88,7 @@ pub(crate) struct RunningTask {
/// Mutable state for a single turn.
#[derive(Default)]
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_approvals: HashMap<String, PendingApproval>,
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
pending_user_input: HashMap<String, oneshot::Sender<AcceptedUserInputResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
@@ -104,6 +104,27 @@ pub(crate) struct TurnState {
pub(crate) last_known_step_context: Option<Arc<StepContext>>,
}
pub(crate) struct PendingApproval {
pub(crate) sender: oneshot::Sender<ReviewDecision>,
pub(crate) kind: codex_protocol::approvals::ExecApprovalKind,
pub(crate) turn_id: String,
}
impl PendingApproval {
pub(crate) fn validate(&self, decision: ReviewDecision) -> ReviewDecision {
if self.kind == codex_protocol::approvals::ExecApprovalKind::HumanCommand
&& !matches!(
decision,
ReviewDecision::Approved | ReviewDecision::Denied { .. } | ReviewDecision::Abort
)
{
ReviewDecision::denied("Managed command policy requires a one-time human approval.")
} else {
decision
}
}
}
/// Host receipt metadata follows the response through the asynchronous tool waiter.
pub(crate) struct AcceptedUserInputResponse {
pub(crate) response: RequestUserInputResponse,
@@ -124,14 +145,24 @@ impl TurnState {
&mut self,
key: String,
tx: oneshot::Sender<ReviewDecision>,
) -> Option<oneshot::Sender<ReviewDecision>> {
self.pending_approvals.insert(key, tx)
kind: codex_protocol::approvals::ExecApprovalKind,
turn_id: String,
) -> Option<PendingApproval> {
self.pending_approvals.insert(
key,
PendingApproval {
sender: tx,
kind,
turn_id,
},
)
}
pub(crate) fn remove_pending_approval(
&mut self,
key: &str,
) -> Option<oneshot::Sender<ReviewDecision>> {
pub(crate) fn pending_approval(&self, key: &str) -> Option<&PendingApproval> {
self.pending_approvals.get(key)
}
pub(crate) fn remove_pending_approval(&mut self, key: &str) -> Option<PendingApproval> {
self.pending_approvals.remove(key)
}
+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 },
}
@@ -972,6 +972,7 @@ impl UnifiedExecProcessManager {
return Err(unreviewable_input_error());
}
let approval_context = ApprovalContext {
exec_requirement: None,
review_context: GuardianReviewContext::from(&context.step_context),
cancellation_token: Some(context.cancellation_token.clone()),
call_id: context.call_id.clone(),