feat(request-permissions) approve with strict review (#19050)

## Summary
Allow the user to approve a request_permissions_tool request with the
condition that all commands in the rest of the turn are reviewed by
guardian, regardless of sandbox status.

## Testing
- [x] Added unit tests
- [x] Ran locally
This commit is contained in:
Dylan Hurd
2026-04-23 01:56:32 +00:00
committed by GitHub
parent c6ab601824
commit 5e71da1424
20 changed files with 609 additions and 134 deletions
+2
View File
@@ -804,6 +804,7 @@ where
let empty = RequestPermissionsResponse {
permissions: Default::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
parent_session
.notify_request_permissions_response(call_id, empty.clone())
@@ -813,6 +814,7 @@ where
response = fut => response.unwrap_or_else(|| RequestPermissionsResponse {
permissions: Default::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}),
}
}
@@ -179,6 +179,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
..RequestPermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
let delegated_cwd = parent_ctx.cwd.join("delegated-cwd");
let cancel_token = CancellationToken::new();
+34 -1
View File
@@ -1893,6 +1893,7 @@ impl Session {
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
});
}
AskForApproval::Granular(granular_config)
@@ -1901,6 +1902,7 @@ impl Session {
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
});
}
AskForApproval::OnFailure
@@ -1944,11 +1946,13 @@ impl Session {
RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
}
ReviewDecision::ApprovedForSession => RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Session,
strict_auto_review: false,
},
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
@@ -1956,16 +1960,19 @@ impl Session {
NetworkPolicyRuleAction::Allow => RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
NetworkPolicyRuleAction::Deny => RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
},
ReviewDecision::Abort | ReviewDecision::Denied | ReviewDecision::TimedOut => {
RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
}
};
@@ -2137,6 +2144,14 @@ impl Session {
response: RequestPermissionsResponse,
cwd: &Path,
) -> RequestPermissionsResponse {
if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) {
return RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
}
if response.permissions.is_empty() {
return response;
}
@@ -2149,6 +2164,7 @@ impl Session {
)
.into(),
scope: response.scope,
strict_auto_review: response.strict_auto_review,
}
}
@@ -2164,7 +2180,11 @@ impl Session {
PermissionGrantScope::Turn => {
if let Some(turn_state) = originating_turn_state {
let mut ts = turn_state.lock().await;
ts.record_granted_permissions(response.permissions.clone().into());
let permissions: PermissionProfile = response.permissions.clone().into();
ts.record_granted_permissions(permissions);
if response.strict_auto_review {
ts.enable_strict_auto_review();
}
}
}
PermissionGrantScope::Session => {
@@ -2185,6 +2205,19 @@ impl Session {
ts.granted_permissions()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn reads must stay consistent with the matching turn state"
)]
pub(crate) async fn strict_auto_review_enabled_for_turn(&self) -> bool {
let active = self.active_turn.lock().await;
let Some(active) = active.as_ref() else {
return false;
};
let ts = active.turn_state.lock().await;
ts.strict_auto_review_enabled()
}
pub(crate) async fn granted_session_permissions(&self) -> Option<PermissionProfile> {
let state = self.state.lock().await;
state.granted_permissions()
+67
View File
@@ -3352,6 +3352,7 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() {
..RequestPermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
)
.await;
@@ -3381,6 +3382,7 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() {
&codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
Some(&originating_turn_state),
)
@@ -3394,6 +3396,67 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() {
assert_eq!(session.granted_turn_permissions().await, None);
}
#[tokio::test]
async fn enable_strict_auto_review_for_turn_uses_originating_turn() {
let (session, _turn_context) = make_session_and_context().await;
let originating_active_turn = ActiveTurn::default();
let originating_turn_state = Arc::clone(&originating_active_turn.turn_state);
*session.active_turn.lock().await = Some(originating_active_turn);
let requested_permissions = RequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..RequestPermissionProfile::default()
};
session
.record_granted_request_permissions_for_turn(
&codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: true,
},
Some(&originating_turn_state),
)
.await;
assert!(
originating_turn_state
.lock()
.await
.strict_auto_review_enabled()
);
}
#[test]
fn strict_auto_review_session_scope_grants_no_permissions() {
let requested_permissions = RequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..RequestPermissionProfile::default()
};
let response = Session::normalize_request_permissions_response(
requested_permissions.clone(),
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: requested_permissions,
scope: PermissionGrantScope::Session,
strict_auto_review: true,
},
std::path::Path::new("/tmp"),
);
assert_eq!(
response,
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
);
}
#[tokio::test]
async fn request_permissions_emits_event_when_granular_policy_allows_requests() {
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
@@ -3421,6 +3484,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
..RequestPermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
let handle = tokio::spawn({
@@ -3536,6 +3600,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: request.permissions,
scope: PermissionGrantScope::Session,
strict_auto_review: false,
},
)
.await;
@@ -3550,6 +3615,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: expected_permissions.clone(),
scope: PermissionGrantScope::Session,
strict_auto_review: false,
};
let response = tokio::time::timeout(StdDuration::from_secs(1), handle)
@@ -3606,6 +3672,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
)
);
@@ -131,6 +131,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
Some(RequestPermissionsResponse {
permissions: requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
})
);
assert_eq!(
@@ -379,6 +380,119 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
assert!(exec_output.output.contains("hi"));
}
#[tokio::test]
async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() {
let server = start_mock_server().await;
let guardian_request_log = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-guardian"),
ev_assistant_message(
"msg-guardian",
&serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The command stays within the strict turn permission grant.",
})
.to_string(),
),
ev_completed("resp-guardian"),
]),
)
.await;
let (mut session, mut turn_context_raw) = make_session_and_context().await;
let active_turn = crate::state::ActiveTurn::default();
let originating_turn_state = Arc::clone(&active_turn.turn_state);
*session.active_turn.lock().await = Some(active_turn);
session
.record_granted_request_permissions_for_turn(
&RequestPermissionsResponse {
permissions: RequestPermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
},
scope: PermissionGrantScope::Turn,
strict_auto_review: true,
},
Some(&originating_turn_state),
)
.await;
turn_context_raw
.approval_policy
.set(AskForApproval::OnFailure)
.expect("test setup should allow updating approval policy");
turn_context_raw
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn_context_raw.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(turn_context_raw.sandbox_policy.get());
turn_context_raw.network_sandbox_policy =
NetworkSandboxPolicy::from(turn_context_raw.sandbox_policy.get());
let mut config = (*turn_context_raw.config).clone();
config.approvals_reviewer = ApprovalsReviewer::User;
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
let config = Arc::new(config);
let models_manager = Arc::new(crate::test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
));
session.services.models_manager = models_manager;
turn_context_raw.config = Arc::clone(&config);
turn_context_raw.provider = create_model_provider(
config.model_provider.clone(),
turn_context_raw.auth_manager.clone(),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let handler = ShellHandler;
let command = if cfg!(windows) {
vec![
"cmd.exe".to_string(),
"/Q".to_string(),
"/D".to_string(),
"/C".to_string(),
"echo hi".to_string(),
]
} else {
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo hi".to_string(),
]
};
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "strict-shell-call".to_string(),
tool_name: codex_tools::ToolName::plain("shell"),
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": command,
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
"timeout_ms": 1_000_u64,
})
.to_string(),
},
})
.await;
let output = expect_text_output(&resp.expect("expected Ok result"));
assert!(output.contains("hi"));
let guardian_request = guardian_request_log.single_request();
assert!(guardian_request.body_contains_text("echo hi"));
}
#[tokio::test]
async fn guardian_allows_unified_exec_additional_permissions_requests_past_policy_validation() {
let (mut session, mut turn_context_raw) = make_session_and_context().await;
+9
View File
@@ -106,6 +106,7 @@ pub(crate) struct TurnState {
pending_input: Vec<ResponseInputItem>,
mailbox_delivery_phase: MailboxDeliveryPhase,
granted_permissions: Option<PermissionProfile>,
strict_auto_review_enabled: bool,
pub(crate) tool_calls: u64,
pub(crate) has_memory_citation: bool,
pub(crate) token_usage_at_turn_start: TokenUsage,
@@ -254,6 +255,14 @@ impl TurnState {
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
self.granted_permissions.clone()
}
pub(crate) fn enable_strict_auto_review(&mut self) {
self.strict_auto_review_enabled = true;
}
pub(crate) fn strict_auto_review_enabled(&self) -> bool {
self.strict_auto_review_enabled
}
}
impl ActiveTurn {
+78 -65
View File
@@ -116,7 +116,8 @@ impl ToolOrchestrator {
let otel = turn_ctx.session_telemetry.clone();
let otel_tn = &tool_ctx.tool_name;
let otel_ci = &tool_ctx.call_id;
let use_guardian = routes_approval_to_guardian(turn_ctx);
let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await;
let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review;
// 1) Approval
let mut already_approved = false;
@@ -126,12 +127,37 @@ impl ToolOrchestrator {
});
match requirement {
ExecApprovalRequirement::Skip { .. } => {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
if strict_auto_review {
let guardian_review_id = Some(new_guardian_review_id());
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: None,
network_approval_context: None,
};
let decision = Self::request_approval(
tool,
req,
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ false,
&otel,
)
.await?;
Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision)
.await?;
already_approved = true;
} else {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
}
}
ExecApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
@@ -152,35 +178,13 @@ impl ToolOrchestrator {
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
use_guardian,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&otel,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
"rejected by user".to_string()
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {}
NetworkPolicyRuleAction::Deny => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
},
}
Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision)
.await?;
already_approved = true;
}
}
@@ -287,9 +291,10 @@ impl ToolOrchestrator {
build_denial_reason_from_output(output.as_ref())
};
// Ask for approval before retrying with the escalated sandbox.
let bypass_retry_approval = tool
.should_bypass_approval(approval_policy, already_approved)
// Strict auto-review approval covers the sandboxed attempt only;
// retrying without the sandbox requires a fresh guardian review.
let bypass_retry_approval = !strict_auto_review
&& tool.should_bypass_approval(approval_policy, already_approved)
&& network_approval_context.is_none();
if !bypass_retry_approval {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
@@ -309,36 +314,13 @@ impl ToolOrchestrator {
&permission_request_run_id,
approval_ctx,
tool_ctx,
use_guardian,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&otel,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
} else {
"rejected by user".to_string()
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {}
NetworkPolicyRuleAction::Deny => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
},
}
Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision)
.await?;
}
let escalated_attempt = SandboxAttempt {
@@ -385,13 +367,15 @@ impl ToolOrchestrator {
permission_request_run_id: &str,
approval_ctx: ApprovalCtx<'_>,
tool_ctx: &ToolCtx,
use_guardian: bool,
evaluate_permission_request_hooks: bool,
otel: &codex_otel::SessionTelemetry,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
if let Some(permission_request) = tool.permission_request_payload(req) {
if evaluate_permission_request_hooks
&& let Some(permission_request) = tool.permission_request_payload(req)
{
match run_permission_request_hooks(
approval_ctx.session,
approval_ctx.turn,
@@ -424,12 +408,12 @@ impl ToolOrchestrator {
}
}
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
let otel_source = if approval_ctx.guardian_review_id.is_some() {
ToolDecisionSource::AutomatedReviewer
} else {
ToolDecisionSource::User
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(
&tool_ctx.tool_name,
&tool_ctx.call_id,
@@ -438,6 +422,35 @@ impl ToolOrchestrator {
);
Ok(decision)
}
async fn reject_if_not_approved(
tool_ctx: &ToolCtx,
guardian_review_id: Option<&str>,
decision: ReviewDecision,
) -> Result<(), ToolError> {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
"rejected by user".to_string()
};
Err(ToolError::Rejected(reason))
}
ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())),
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => Ok(()),
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => Ok(()),
NetworkPolicyRuleAction::Deny => {
Err(ToolError::Rejected("rejected by user".to_string()))
}
},
}
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
@@ -139,14 +139,14 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
let changes = req.changes.clone();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
if let Some(review_id) = guardian_review_id {
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
}
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
if let Some(reason) = retry_reason {
let rx_approve = session
.request_patch_approval(