mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user