mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Use issuing step settings for approval decisions (#40821)
## Why Tool actions and approval retries can outlive the step that issued them. If approval handling reads newer turn settings, a delayed action can use the wrong approval policy or reviewer. ## What changed - Capture the approval policy and reviewer in `GuardianReviewContext` from the issuing step. - Use step settings for command, patch, permission, sandbox-retry, and remote-network approval decisions. - Use the active task's published settings for background network approvals and long-lived Unix shell interception. - Preserve explicit MCP reviewer overrides. ## Testing Added regression coverage for permission requests, same-turn network policy changes, delayed commands after a new turn starts, sandbox retries, patch policy selection, and MCP reviewer overrides. GitOrigin-RevId: b7e84a2e1e79a71502272b15b5ef5b0e5643f8b0
This commit is contained in:
1 parent
21c58c90f2
commit
a26f1806a4
16 files changed
+671
-60
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
use crate::function_tool::FunctionCallError;
|
use crate::function_tool::FunctionCallError;
|
||||||
use crate::safety::SafetyCheck;
|
use crate::safety::SafetyCheck;
|
||||||
use crate::safety::assess_patch_safety;
|
use crate::safety::assess_patch_safety;
|
||||||
use crate::session::turn_context::TurnContext;
|
use crate::session::step_context::StepContext;
|
||||||
use crate::session::turn_context::TurnEnvironment;
|
use crate::session::turn_context::TurnEnvironment;
|
||||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||||
use codex_apply_patch::ApplyPatchAction;
|
use codex_apply_patch::ApplyPatchAction;
|
||||||
@@ -20,14 +20,14 @@ pub(crate) struct ApplyPatchRuntimeInvocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_apply_patch(
|
pub(crate) fn prepare_apply_patch(
|
||||||
turn_context: &TurnContext,
|
step_context: &StepContext,
|
||||||
turn_environment: &TurnEnvironment,
|
turn_environment: &TurnEnvironment,
|
||||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||||
action: ApplyPatchAction,
|
action: ApplyPatchAction,
|
||||||
) -> Result<ApplyPatchRuntimeInvocation, FunctionCallError> {
|
) -> Result<ApplyPatchRuntimeInvocation, FunctionCallError> {
|
||||||
match assess_patch_safety(
|
match assess_patch_safety(
|
||||||
&action,
|
&action,
|
||||||
turn_context.approval_policy(),
|
step_context.settings.approval_policy(),
|
||||||
turn_environment.permission_profile(),
|
turn_environment.permission_profile(),
|
||||||
file_system_sandbox_policy,
|
file_system_sandbox_policy,
|
||||||
&action.cwd,
|
&action.cwd,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::session::tests::make_session_and_context;
|
||||||
|
use crate::session::tests::update_selected_settings_for_test;
|
||||||
|
use codex_protocol::models::PermissionProfile;
|
||||||
|
use codex_protocol::models::PermissionProfileSnapshot;
|
||||||
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -20,3 +26,45 @@ fn convert_apply_patch_maps_add_variant() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prepare_apply_patch_uses_action_policy_before_turn_policy() {
|
||||||
|
let (_, mut turn) = make_session_and_context().await;
|
||||||
|
Arc::make_mut(&mut turn.config)
|
||||||
|
.permissions
|
||||||
|
.approval_policy
|
||||||
|
.set(AskForApproval::Never)
|
||||||
|
.expect("set admitted turn approval policy");
|
||||||
|
let mut step = StepContext::for_test(Arc::new(turn));
|
||||||
|
let step_settings = &mut Arc::get_mut(&mut step)
|
||||||
|
.expect("unshared test step")
|
||||||
|
.settings;
|
||||||
|
update_selected_settings_for_test(Arc::make_mut(step_settings), |selected| {
|
||||||
|
selected
|
||||||
|
.approval_policy
|
||||||
|
.set(AskForApproval::OnRequest)
|
||||||
|
.expect("set captured approval policy");
|
||||||
|
});
|
||||||
|
let tmp = tempdir().expect("tmp");
|
||||||
|
let path = tmp.path().join("outside.txt");
|
||||||
|
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||||
|
let action = ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string());
|
||||||
|
let permission_profile = PermissionProfile::read_only();
|
||||||
|
let file_system_policy = permission_profile.file_system_sandbox_policy();
|
||||||
|
let mut environment = step
|
||||||
|
.environments
|
||||||
|
.primary()
|
||||||
|
.expect("primary environment")
|
||||||
|
.clone();
|
||||||
|
environment.config_mut().permission_profile =
|
||||||
|
PermissionProfileSnapshot::legacy(permission_profile);
|
||||||
|
|
||||||
|
let prepared = prepare_apply_patch(&step, &environment, &file_system_policy, action)
|
||||||
|
.expect("issuing action policy should request approval");
|
||||||
|
|
||||||
|
assert!(!prepared.auto_approved);
|
||||||
|
assert!(matches!(
|
||||||
|
prepared.exec_approval_requirement,
|
||||||
|
ExecApprovalRequirement::NeedsApproval { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -20,12 +20,18 @@ mod review_session;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use codex_protocol::config_types::ApprovalsReviewer;
|
||||||
|
use codex_protocol::config_types::ReasoningSummary;
|
||||||
|
use codex_protocol::openai_models::ModelInfo;
|
||||||
|
use codex_protocol::openai_models::ReasoningEffort;
|
||||||
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||||
use crate::session::step_context::StepContext;
|
use crate::session::step_context::StepContext;
|
||||||
|
use crate::session::step_settings::ResolvedStepSettings;
|
||||||
use crate::session::turn_context::TurnContext;
|
use crate::session::turn_context::TurnContext;
|
||||||
use crate::tools::sandboxing::ApprovalRequestReasons;
|
use crate::tools::sandboxing::ApprovalRequestReasons;
|
||||||
|
|
||||||
@@ -69,18 +75,44 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
|
|||||||
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
|
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
|
||||||
const TRUNCATION_TAG: &str = "truncated";
|
const TRUNCATION_TAG: &str = "truncated";
|
||||||
|
|
||||||
/// Built from the originating StepContext when available.
|
/// Captures review inputs from the issuing step without retaining its MCP bindings or tool router.
|
||||||
/// There are currently two exceptions-- turn-only callers (background network approvals, reviewer
|
/// Background network approvals and Unix interception use the active task's resolved settings.
|
||||||
/// prewarming, etc.) and interactive Unix shells that can outlive the step that started them.
|
/// Startup reviewer prewarming intentionally uses turn-only inputs because it has no issuing step.
|
||||||
|
///
|
||||||
|
/// MCP elicitation reviews still use turn-only inputs.
|
||||||
/// TODO(sayan): See if we can find a way to model those as StepContext as well without holding
|
/// TODO(sayan): See if we can find a way to model those as StepContext as well without holding
|
||||||
/// step-scoped things past their lifetime (like MCP bindings)
|
/// step-scoped things past their lifetime (like MCP bindings)
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct GuardianReviewContext {
|
pub(crate) struct GuardianReviewContext {
|
||||||
turn: Arc<TurnContext>,
|
turn: Arc<TurnContext>,
|
||||||
environments: TurnEnvironmentSnapshot,
|
environments: TurnEnvironmentSnapshot,
|
||||||
|
// Model and reasoning inputs are carried for the follow-up Guardian and V2 migrations.
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) model_info: Arc<ModelInfo>,
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) reasoning_effort: Option<ReasoningEffort>,
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) reasoning_summary: ReasoningSummary,
|
||||||
|
pub(crate) approval_policy: AskForApproval,
|
||||||
|
pub(crate) approvals_reviewer: ApprovalsReviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GuardianReviewContext {
|
impl GuardianReviewContext {
|
||||||
|
pub(crate) fn from_resolved_settings(
|
||||||
|
turn: Arc<TurnContext>,
|
||||||
|
settings: &ResolvedStepSettings,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
environments: turn.environments.clone(),
|
||||||
|
model_info: Arc::clone(&settings.model_info),
|
||||||
|
reasoning_effort: settings.reasoning_effort().cloned(),
|
||||||
|
reasoning_summary: settings.reasoning_summary,
|
||||||
|
approval_policy: settings.approval_policy(),
|
||||||
|
approvals_reviewer: settings.approvals_reviewer(),
|
||||||
|
turn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn turn(&self) -> &Arc<TurnContext> {
|
pub(crate) fn turn(&self) -> &Arc<TurnContext> {
|
||||||
&self.turn
|
&self.turn
|
||||||
}
|
}
|
||||||
@@ -95,6 +127,11 @@ impl From<&Arc<StepContext>> for GuardianReviewContext {
|
|||||||
Self {
|
Self {
|
||||||
turn: Arc::clone(&step.turn),
|
turn: Arc::clone(&step.turn),
|
||||||
environments: step.environments.clone(),
|
environments: step.environments.clone(),
|
||||||
|
model_info: Arc::clone(&step.settings.model_info),
|
||||||
|
reasoning_effort: step.settings.reasoning_effort().cloned(),
|
||||||
|
reasoning_summary: step.settings.reasoning_summary,
|
||||||
|
approval_policy: step.settings.approval_policy(),
|
||||||
|
approvals_reviewer: step.settings.approvals_reviewer(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +140,11 @@ impl From<Arc<TurnContext>> for GuardianReviewContext {
|
|||||||
fn from(turn: Arc<TurnContext>) -> Self {
|
fn from(turn: Arc<TurnContext>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
environments: turn.environments.clone(),
|
environments: turn.environments.clone(),
|
||||||
|
model_info: Arc::clone(turn.model_info()),
|
||||||
|
reasoning_effort: turn.reasoning_effort().cloned(),
|
||||||
|
reasoning_summary: turn.reasoning_summary(),
|
||||||
|
approval_policy: turn.approval_policy(),
|
||||||
|
approvals_reviewer: turn.config.approvals_reviewer,
|
||||||
turn,
|
turn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use codex_core_plugins::PluginCommandAttribution;
|
use codex_core_plugins::PluginCommandAttribution;
|
||||||
use codex_plugin::PluginId;
|
use codex_plugin::PluginId;
|
||||||
@@ -13,6 +14,7 @@ use tokio::sync::watch;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use super::tests::make_session_and_context_with_rx;
|
use super::tests::make_session_and_context_with_rx;
|
||||||
|
use crate::session::step_context::StepContext;
|
||||||
use crate::state::ActiveTurn;
|
use crate::state::ActiveTurn;
|
||||||
|
|
||||||
async fn wait_until_held(pause_state: &mut watch::Receiver<bool>) {
|
async fn wait_until_held(pause_state: &mut watch::Receiver<bool>) {
|
||||||
@@ -126,7 +128,7 @@ async fn permission_request_holds_an_elicitation_until_response() {
|
|||||||
.selection();
|
.selection();
|
||||||
session
|
session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
&turn_context,
|
&StepContext::for_test(Arc::clone(&turn_context)),
|
||||||
"call-1".to_string(),
|
"call-1".to_string(),
|
||||||
RequestPermissionsArgs {
|
RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use crate::image_preparation::unified_image_budget_enabled;
|
|||||||
use crate::parse_turn_item;
|
use crate::parse_turn_item;
|
||||||
use crate::realtime_conversation::RealtimeConversationManager;
|
use crate::realtime_conversation::RealtimeConversationManager;
|
||||||
use crate::session::step_context::StepContext;
|
use crate::session::step_context::StepContext;
|
||||||
|
use crate::session::step_settings::ResolvedStepSettings;
|
||||||
use crate::session::step_settings::StepSettings;
|
use crate::session::step_settings::StepSettings;
|
||||||
use crate::session::turn_context::TurnEnvironment;
|
use crate::session::turn_context::TurnEnvironment;
|
||||||
use crate::session_prefix::format_inter_agent_completion_message;
|
use crate::session_prefix::format_inter_agent_completion_message;
|
||||||
@@ -2604,15 +2605,16 @@ impl Session {
|
|||||||
)]
|
)]
|
||||||
pub(crate) async fn request_permissions_for_environment(
|
pub(crate) async fn request_permissions_for_environment(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
review_context: impl Into<crate::guardian::GuardianReviewContext>,
|
step_context: &Arc<StepContext>,
|
||||||
call_id: String,
|
call_id: String,
|
||||||
args: RequestPermissionsArgs,
|
args: RequestPermissionsArgs,
|
||||||
environment: TurnEnvironmentSelection,
|
environment: TurnEnvironmentSelection,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
) -> Option<RequestPermissionsResponse> {
|
) -> Option<RequestPermissionsResponse> {
|
||||||
let review_context = review_context.into();
|
let turn_context = &step_context.turn;
|
||||||
let turn_context = review_context.turn();
|
let approval_policy = step_context.settings.approval_policy();
|
||||||
match turn_context.as_ref().approval_policy() {
|
let approvals_reviewer = step_context.settings.approvals_reviewer();
|
||||||
|
match approval_policy {
|
||||||
AskForApproval::Never => {
|
AskForApproval::Never => {
|
||||||
return Some(RequestPermissionsResponse {
|
return Some(RequestPermissionsResponse {
|
||||||
permissions: RequestPermissionProfile::default(),
|
permissions: RequestPermissionProfile::default(),
|
||||||
@@ -2648,7 +2650,8 @@ impl Session {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) {
|
if crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer)
|
||||||
|
{
|
||||||
let originating_turn_state = {
|
let originating_turn_state = {
|
||||||
let active = self.active_turn.lock().await;
|
let active = self.active_turn.lock().await;
|
||||||
active.as_ref().map(|active| Arc::clone(&active.turn_state))
|
active.as_ref().map(|active| Arc::clone(&active.turn_state))
|
||||||
@@ -2660,7 +2663,7 @@ impl Session {
|
|||||||
permissions: requested_permissions.clone(),
|
permissions: requested_permissions.clone(),
|
||||||
};
|
};
|
||||||
let approval_context = ApprovalContext {
|
let approval_context = ApprovalContext {
|
||||||
review_context: review_context.clone(),
|
review_context: crate::guardian::GuardianReviewContext::from(step_context),
|
||||||
cancellation_token: Some(cancellation_token.clone()),
|
cancellation_token: Some(cancellation_token.clone()),
|
||||||
call_id,
|
call_id,
|
||||||
tool_name: ToolName::plain("request_permissions"),
|
tool_name: ToolName::plain("request_permissions"),
|
||||||
@@ -2982,12 +2985,14 @@ impl Session {
|
|||||||
)]
|
)]
|
||||||
pub(crate) async fn active_turn_context_and_strict_auto_review(
|
pub(crate) async fn active_turn_context_and_strict_auto_review(
|
||||||
&self,
|
&self,
|
||||||
) -> Option<(Arc<TurnContext>, bool)> {
|
) -> Option<(Arc<TurnContext>, Arc<ResolvedStepSettings>, bool)> {
|
||||||
let active = self.active_turn.lock().await;
|
let active = self.active_turn.lock().await;
|
||||||
let active = active.as_ref()?;
|
let active = active.as_ref()?;
|
||||||
let turn_context = Arc::clone(&active.task.as_ref()?.turn_context);
|
let task = active.task.as_ref()?;
|
||||||
|
let turn_context = Arc::clone(&task.turn_context);
|
||||||
|
let step_settings = turn_context.current_settings.load_full();
|
||||||
let ts = active.turn_state.lock().await;
|
let ts = active.turn_state.lock().await;
|
||||||
Some((turn_context, ts.strict_auto_review_enabled()))
|
Some((turn_context, step_settings, ts.strict_auto_review_enabled()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn granted_session_permissions(
|
pub(crate) async fn granted_session_permissions(
|
||||||
|
|||||||
@@ -1432,8 +1432,6 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
|
|||||||
.primary()
|
.primary()
|
||||||
.expect("turn should have a primary environment"),
|
.expect("turn should have a primary environment"),
|
||||||
&tool_ctx,
|
&tool_ctx,
|
||||||
turn.as_ref(),
|
|
||||||
AskForApproval::Never,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("probe runtime should succeed");
|
.expect("probe runtime should succeed");
|
||||||
@@ -6949,7 +6947,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
|||||||
.selection();
|
.selection();
|
||||||
session
|
session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
turn_context.as_ref(),
|
&StepContext::for_test(Arc::clone(turn_context.as_ref())),
|
||||||
call_id,
|
call_id,
|
||||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
@@ -7203,7 +7201,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
|
|||||||
.selection();
|
.selection();
|
||||||
session
|
session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
turn_context.as_ref(),
|
&StepContext::for_test(Arc::clone(turn_context.as_ref())),
|
||||||
call_id,
|
call_id,
|
||||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
@@ -7295,7 +7293,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
|
|||||||
.selection();
|
.selection();
|
||||||
let response = session
|
let response = session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
turn_context.as_ref(),
|
&StepContext::for_test(Arc::clone(turn_context.as_ref())),
|
||||||
call_id,
|
call_id,
|
||||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::compact::InitialContextInjection;
|
use crate::compact::InitialContextInjection;
|
||||||
|
use crate::config::Constrained;
|
||||||
use crate::exec_policy::ExecPolicyManager;
|
use crate::exec_policy::ExecPolicyManager;
|
||||||
use crate::guardian::GUARDIAN_REVIEWER_NAME;
|
use crate::guardian::GUARDIAN_REVIEWER_NAME;
|
||||||
use crate::plugins::plugins_manager_for_config;
|
use crate::plugins::plugins_manager_for_config;
|
||||||
use crate::sandboxing::SandboxPermissions;
|
use crate::sandboxing::SandboxPermissions;
|
||||||
use crate::session::step_context::StepContext;
|
use crate::session::step_context::StepContext;
|
||||||
use crate::session::tests::update_turn_settings_for_test;
|
use crate::session::tests::update_turn_settings_for_test;
|
||||||
|
use crate::session::turn_context::NewTurnContextOptions;
|
||||||
use crate::test_support::models_manager_with_provider;
|
use crate::test_support::models_manager_with_provider;
|
||||||
use crate::tools::context::ToolCallSource;
|
use crate::tools::context::ToolCallSource;
|
||||||
use crate::tools::context::ToolOutput;
|
use crate::tools::context::ToolOutput;
|
||||||
use crate::tools::context::ToolPayload;
|
use crate::tools::context::ToolPayload;
|
||||||
|
use crate::tools::orchestrator::ToolOrchestrator;
|
||||||
|
use crate::tools::sandboxing::Approvable;
|
||||||
|
use crate::tools::sandboxing::ApprovalAction;
|
||||||
|
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||||
|
use crate::tools::sandboxing::SandboxAttempt;
|
||||||
|
use crate::tools::sandboxing::Sandboxable;
|
||||||
|
use crate::tools::sandboxing::ToolCtx;
|
||||||
|
use crate::tools::sandboxing::ToolError;
|
||||||
|
use crate::tools::sandboxing::ToolRuntime;
|
||||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||||
use codex_config::ConfigLayerEntry;
|
use codex_config::ConfigLayerEntry;
|
||||||
use codex_config::ConfigLayerSource;
|
use codex_config::ConfigLayerSource;
|
||||||
@@ -18,10 +29,15 @@ use codex_config::ConfigRequirementsToml;
|
|||||||
use codex_exec_server::EnvironmentManager;
|
use codex_exec_server::EnvironmentManager;
|
||||||
use codex_execpolicy::Decision;
|
use codex_execpolicy::Decision;
|
||||||
use codex_execpolicy::Evaluation;
|
use codex_execpolicy::Evaluation;
|
||||||
|
use codex_execpolicy::Policy;
|
||||||
use codex_execpolicy::RuleMatch;
|
use codex_execpolicy::RuleMatch;
|
||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_model_provider::create_model_provider;
|
use codex_model_provider::create_model_provider;
|
||||||
|
use codex_network_proxy::NetworkDecision;
|
||||||
|
use codex_network_proxy::NetworkPolicyRequest;
|
||||||
|
use codex_network_proxy::NetworkProtocol;
|
||||||
use codex_protocol::config_types::ApprovalsReviewer;
|
use codex_protocol::config_types::ApprovalsReviewer;
|
||||||
|
use codex_protocol::error::SandboxErr;
|
||||||
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
|
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
use codex_protocol::models::ContentItemKind;
|
use codex_protocol::models::ContentItemKind;
|
||||||
@@ -50,6 +66,7 @@ use std::fs;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
use test_case::test_case;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
@@ -72,6 +89,93 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn activate_turn_with_new_review_authority(session: &Arc<Session>) -> Arc<TurnContext> {
|
||||||
|
let (current_turn, _) = session
|
||||||
|
.new_turn_with_sub_id(
|
||||||
|
"current-authority-turn".to_string(),
|
||||||
|
SessionSettingsUpdate {
|
||||||
|
step_settings: StepSettingsUpdate {
|
||||||
|
approval_policy: Some(AskForApproval::Never),
|
||||||
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
permission_profile: Some(codex_protocol::models::PermissionProfile::Disabled),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
NewTurnContextOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("next turn should accept different approval authority");
|
||||||
|
session
|
||||||
|
.start_task(
|
||||||
|
current_turn,
|
||||||
|
Vec::new(),
|
||||||
|
super::NeverEndingTask {
|
||||||
|
kind: crate::state::TaskKind::Regular,
|
||||||
|
listen_to_cancellation_token: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (active_turn, _, _) = session
|
||||||
|
.active_turn_context_and_strict_auto_review()
|
||||||
|
.await
|
||||||
|
.expect("next turn should have active review authority");
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
active_turn.approval_policy(),
|
||||||
|
active_turn.config.approvals_reviewer
|
||||||
|
),
|
||||||
|
(AskForApproval::Never, ApprovalsReviewer::AutoReview)
|
||||||
|
);
|
||||||
|
active_turn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captured_step_with_user_reviewer(
|
||||||
|
turn: &mut Arc<TurnContext>,
|
||||||
|
admitted_policy: AskForApproval,
|
||||||
|
captured_policy: AskForApproval,
|
||||||
|
) -> Arc<StepContext> {
|
||||||
|
let config = Arc::make_mut(
|
||||||
|
&mut Arc::get_mut(turn)
|
||||||
|
.expect("turn should not be shared")
|
||||||
|
.config,
|
||||||
|
);
|
||||||
|
config
|
||||||
|
.permissions
|
||||||
|
.approval_policy
|
||||||
|
.set(admitted_policy)
|
||||||
|
.expect("set admitted turn approval policy");
|
||||||
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||||
|
|
||||||
|
let mut step = StepContext::for_test(Arc::clone(turn));
|
||||||
|
let captured = Arc::get_mut(&mut step).expect("step context should not be shared");
|
||||||
|
update_selected_settings_for_test(Arc::make_mut(&mut captured.settings), |selected| {
|
||||||
|
selected
|
||||||
|
.approval_policy
|
||||||
|
.set(captured_policy)
|
||||||
|
.expect("set captured approval policy");
|
||||||
|
selected.approvals_reviewer = ApprovalsReviewer::User;
|
||||||
|
});
|
||||||
|
step
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_exec_approval(
|
||||||
|
events: &async_channel::Receiver<Event>,
|
||||||
|
) -> codex_protocol::protocol::ExecApprovalRequestEvent {
|
||||||
|
timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if let EventMsg::ExecApprovalRequest(approval) =
|
||||||
|
events.recv().await.expect("receive approval event").msg
|
||||||
|
{
|
||||||
|
break approval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("captured action should request user approval")
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
@@ -138,6 +242,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
|||||||
evidence.record("js", "cell", "image", vec![image]);
|
evidence.record("js", "cell", "image", vec![image]);
|
||||||
let session = Arc::new(session);
|
let session = Arc::new(session);
|
||||||
let turn_context = Arc::new(turn_context_raw);
|
let turn_context = Arc::new(turn_context_raw);
|
||||||
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
||||||
|
|
||||||
let requested_permissions = RequestPermissionProfile {
|
let requested_permissions = RequestPermissionProfile {
|
||||||
network: Some(NetworkPermissions {
|
network: Some(NetworkPermissions {
|
||||||
@@ -153,7 +258,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
|||||||
let response = tokio::time::timeout(
|
let response = tokio::time::timeout(
|
||||||
Duration::from_secs(45),
|
Duration::from_secs(45),
|
||||||
session.request_permissions_for_environment(
|
session.request_permissions_for_environment(
|
||||||
&turn_context,
|
&step_context,
|
||||||
"perm-call-1".to_string(),
|
"perm-call-1".to_string(),
|
||||||
RequestPermissionsArgs {
|
RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
@@ -177,7 +282,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
|||||||
);
|
);
|
||||||
let second_response = session
|
let second_response = session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
&turn_context,
|
&step_context,
|
||||||
"perm-call-2".to_string(),
|
"perm-call-2".to_string(),
|
||||||
RequestPermissionsArgs {
|
RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
@@ -207,6 +312,84 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
|||||||
assert!(guardian_request.body_contains_text("need network"));
|
assert!(guardian_request.body_contains_text("need network"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn request_permissions_uses_issuing_step_policy_and_reviewer() {
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let guardian_requests = mount_sse_once(
|
||||||
|
&server,
|
||||||
|
sse(vec![
|
||||||
|
ev_assistant_message("guardian", r#"{"outcome":"allow"}"#),
|
||||||
|
ev_completed("guardian-review"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (session, turn, _) = make_session_and_context_with_auth_and_config_and_rx(
|
||||||
|
CodexAuth::from_api_key("Test API Key"),
|
||||||
|
Vec::new(),
|
||||||
|
|config| {
|
||||||
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||||
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||||
|
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(Feature::GuardianApproval)
|
||||||
|
.expect("enable Guardian");
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||||
|
let mut step = StepContext::for_test(turn);
|
||||||
|
let captured = Arc::get_mut(&mut step).expect("unshared step");
|
||||||
|
// The issuing step differs from the admitted Never/User turn.
|
||||||
|
update_selected_settings_for_test(Arc::make_mut(&mut captured.settings), |selected| {
|
||||||
|
selected
|
||||||
|
.approval_policy
|
||||||
|
.set(AskForApproval::OnRequest)
|
||||||
|
.expect("set step policy");
|
||||||
|
selected.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||||
|
});
|
||||||
|
let permissions = RequestPermissionProfile {
|
||||||
|
network: Some(NetworkPermissions {
|
||||||
|
enabled: Some(true),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
session.request_permissions_for_environment(
|
||||||
|
&step,
|
||||||
|
"step-permissions".to_string(),
|
||||||
|
RequestPermissionsArgs {
|
||||||
|
environment_id: None,
|
||||||
|
reason: Some("need network".to_string()),
|
||||||
|
permissions: permissions.clone(),
|
||||||
|
},
|
||||||
|
step.environments
|
||||||
|
.primary()
|
||||||
|
.expect("primary environment")
|
||||||
|
.selection(),
|
||||||
|
CancellationToken::new(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("issuing step should route to Guardian without a user approval");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response,
|
||||||
|
Some(RequestPermissionsResponse {
|
||||||
|
permissions,
|
||||||
|
scope: PermissionGrantScope::Turn,
|
||||||
|
strict_auto_review: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
guardian_requests
|
||||||
|
.single_request()
|
||||||
|
.body_contains_text("request_permissions")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn request_permissions_guardian_review_stops_when_cancelled() {
|
async fn request_permissions_guardian_review_stops_when_cancelled() {
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
@@ -268,7 +451,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() {
|
|||||||
.selection();
|
.selection();
|
||||||
session
|
session
|
||||||
.request_permissions_for_environment(
|
.request_permissions_for_environment(
|
||||||
&turn_context,
|
&StepContext::for_test(Arc::clone(&turn_context)),
|
||||||
"perm-call-cancelled".to_string(),
|
"perm-call-cancelled".to_string(),
|
||||||
RequestPermissionsArgs {
|
RequestPermissionsArgs {
|
||||||
environment_id: None,
|
environment_id: None,
|
||||||
@@ -534,6 +717,284 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_s
|
|||||||
assert!(guardian_request.body_contains_text("echo hi"));
|
assert!(guardian_request.body_contains_text("echo hi"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(AskForApproval::Never; "policy_precheck")]
|
||||||
|
#[test_case(AskForApproval::OnRequest; "reviewer_routing")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn network_approval_uses_published_task_authority_within_same_turn(
|
||||||
|
admitted_policy: AskForApproval,
|
||||||
|
) {
|
||||||
|
let (session, turn, events) = make_session_and_context_with_auth_and_config_and_rx(
|
||||||
|
CodexAuth::from_api_key("Test API Key"),
|
||||||
|
Vec::new(),
|
||||||
|
move |config| {
|
||||||
|
config.permissions.approval_policy = Constrained::allow_any(admitted_policy);
|
||||||
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||||
|
config
|
||||||
|
.permissions
|
||||||
|
.set_permission_profile(
|
||||||
|
codex_protocol::models::PermissionProfile::workspace_write(),
|
||||||
|
)
|
||||||
|
.expect("set managed permissions");
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
session
|
||||||
|
.start_task(
|
||||||
|
Arc::clone(&turn),
|
||||||
|
Vec::new(),
|
||||||
|
super::NeverEndingTask {
|
||||||
|
kind: crate::state::TaskKind::Regular,
|
||||||
|
listen_to_cancellation_token: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Inject later-step authority directly while live policy changes remain gated.
|
||||||
|
{
|
||||||
|
let active = session.active_turn.lock().await;
|
||||||
|
let task = active
|
||||||
|
.as_ref()
|
||||||
|
.expect("active turn")
|
||||||
|
.task
|
||||||
|
.as_ref()
|
||||||
|
.expect("active task");
|
||||||
|
let mut settings = task.turn_context.current_settings.load_full();
|
||||||
|
update_selected_settings_for_test(Arc::make_mut(&mut settings), |selected| {
|
||||||
|
selected
|
||||||
|
.approval_policy
|
||||||
|
.set(AskForApproval::OnRequest)
|
||||||
|
.expect("update policy");
|
||||||
|
selected.approvals_reviewer = ApprovalsReviewer::User;
|
||||||
|
});
|
||||||
|
task.turn_context.current_settings.store(settings);
|
||||||
|
}
|
||||||
|
let decision = session
|
||||||
|
.services
|
||||||
|
.network_approval
|
||||||
|
.handle_inline_policy_request(
|
||||||
|
Arc::clone(&session),
|
||||||
|
NetworkPolicyRequest {
|
||||||
|
protocol: NetworkProtocol::Http,
|
||||||
|
host: "example.com".to_string(),
|
||||||
|
port: 80,
|
||||||
|
environment_id: None,
|
||||||
|
client_addr: None,
|
||||||
|
method: None,
|
||||||
|
command: None,
|
||||||
|
exec_policy_hint: None,
|
||||||
|
execution_id: None,
|
||||||
|
disconnect: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokio::pin!(decision);
|
||||||
|
let approval = timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut decision => panic!("expected user network approval, got {result:?}"),
|
||||||
|
event = events.recv() => {
|
||||||
|
match event.expect("approval event").msg {
|
||||||
|
EventMsg::ExecApprovalRequest(approval) => break approval,
|
||||||
|
EventMsg::GuardianAssessment(_) => panic!("expected the current user reviewer"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("network approval requested");
|
||||||
|
assert_eq!(approval.turn_id, turn.sub_id);
|
||||||
|
session
|
||||||
|
.notify_approval(&approval.call_id, ReviewDecision::Approved)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
timeout(Duration::from_secs(5), decision)
|
||||||
|
.await
|
||||||
|
.expect("network decision"),
|
||||||
|
NetworkDecision::Allow
|
||||||
|
);
|
||||||
|
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delayed_exec_command_uses_its_captured_authority_after_next_turn_starts() {
|
||||||
|
let (mut session, mut action_turn, events) = make_session_and_context_with_rx().await;
|
||||||
|
// Windows can allow safe echo commands without prompting when its sandbox is disabled.
|
||||||
|
let mut exec_policy = Policy::empty();
|
||||||
|
exec_policy
|
||||||
|
.add_prefix_rule(
|
||||||
|
&["echo".to_string(), "captured-action-authority".to_string()],
|
||||||
|
Decision::Prompt,
|
||||||
|
)
|
||||||
|
.expect("test command should require approval");
|
||||||
|
Arc::get_mut(&mut session)
|
||||||
|
.expect("session should not be shared")
|
||||||
|
.services
|
||||||
|
.exec_policy = Arc::new(ExecPolicyManager::new(Arc::new(exec_policy)));
|
||||||
|
let step_context = captured_step_with_user_reviewer(
|
||||||
|
&mut action_turn,
|
||||||
|
AskForApproval::Never,
|
||||||
|
AskForApproval::OnRequest,
|
||||||
|
);
|
||||||
|
let current_turn = activate_turn_with_new_review_authority(&session).await;
|
||||||
|
assert_ne!(action_turn.sub_id, current_turn.sub_id);
|
||||||
|
|
||||||
|
let call_id = "delayed-captured-authority-shell-command";
|
||||||
|
let command = "echo captured-action-authority";
|
||||||
|
let handler = crate::tools::handlers::ExecCommandHandler::default();
|
||||||
|
let invocation = handler.handle(ToolInvocation {
|
||||||
|
session: Arc::clone(&session),
|
||||||
|
turn: Arc::clone(&action_turn),
|
||||||
|
step_context,
|
||||||
|
cancellation_token: CancellationToken::new(),
|
||||||
|
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
tool_name: codex_tools::ToolName::plain("exec_command"),
|
||||||
|
source: ToolCallSource::Direct,
|
||||||
|
payload: ToolPayload::Function {
|
||||||
|
arguments: serde_json::json!({
|
||||||
|
"cmd": command,
|
||||||
|
"login": false,
|
||||||
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
||||||
|
"justification": "verify captured action authority",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let approve = async {
|
||||||
|
let approval = next_exec_approval(&events).await;
|
||||||
|
assert_eq!(approval.call_id, call_id);
|
||||||
|
assert_eq!(approval.turn_id, action_turn.sub_id);
|
||||||
|
assert!(approval.command.join(" ").contains(command));
|
||||||
|
session
|
||||||
|
.notify_approval(call_id, ReviewDecision::Approved)
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (output, ()) = tokio::join!(invocation, approve);
|
||||||
|
let output = output.expect("approved shell command should succeed");
|
||||||
|
assert!(expect_text_output(output.as_ref()).contains("captured-action-authority"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sandbox_denied_retry_uses_the_action_policy_and_reviewer() {
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DeniedOnceRuntime {
|
||||||
|
attempts: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Approvable<TurnEnvironment> for DeniedOnceRuntime {
|
||||||
|
fn exec_approval_requirement(
|
||||||
|
&self,
|
||||||
|
_request: &TurnEnvironment,
|
||||||
|
) -> Option<ExecApprovalRequirement> {
|
||||||
|
Some(ExecApprovalRequirement::Skip {
|
||||||
|
bypass_sandbox: false,
|
||||||
|
proposed_execpolicy_amendment: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn approval_action(
|
||||||
|
&self,
|
||||||
|
request: &TurnEnvironment,
|
||||||
|
call_id: &str,
|
||||||
|
) -> std::io::Result<ApprovalAction> {
|
||||||
|
Ok(ApprovalAction::ExecCommand {
|
||||||
|
id: call_id.to_string(),
|
||||||
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||||
|
command: vec!["echo".to_string(), "sandbox-retry".to_string()],
|
||||||
|
hook_command: "echo sandbox-retry".to_string(),
|
||||||
|
cwd: request.cwd().clone(),
|
||||||
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||||
|
additional_permissions: None,
|
||||||
|
justification: None,
|
||||||
|
tty: false,
|
||||||
|
proposed_execpolicy_amendment: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sandboxable for DeniedOnceRuntime {
|
||||||
|
fn sandbox_preference(&self) -> codex_sandboxing::SandboxablePreference {
|
||||||
|
codex_sandboxing::SandboxablePreference::Auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRuntime<TurnEnvironment, String> for DeniedOnceRuntime {
|
||||||
|
fn turn_environment<'a>(&self, request: &'a TurnEnvironment) -> &'a TurnEnvironment {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&mut self,
|
||||||
|
_request: &TurnEnvironment,
|
||||||
|
_attempt: &SandboxAttempt<'_>,
|
||||||
|
_context: &ToolCtx,
|
||||||
|
) -> Result<String, ToolError> {
|
||||||
|
self.attempts += 1;
|
||||||
|
if self.attempts == 1 {
|
||||||
|
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||||
|
output: Box::new(ExecToolCallOutput {
|
||||||
|
exit_code: 1,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
network_policy_decision: None,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok("sandbox-retry-succeeded".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (session, mut action_turn, events) = make_session_and_context_with_rx().await;
|
||||||
|
let step_context = captured_step_with_user_reviewer(
|
||||||
|
&mut action_turn,
|
||||||
|
AskForApproval::OnRequest,
|
||||||
|
AskForApproval::UnlessTrusted,
|
||||||
|
);
|
||||||
|
|
||||||
|
let current_turn = activate_turn_with_new_review_authority(&session).await;
|
||||||
|
assert_ne!(action_turn.sub_id, current_turn.sub_id);
|
||||||
|
|
||||||
|
let call_id = "captured-action-sandbox-retry";
|
||||||
|
let context = ToolCtx {
|
||||||
|
session: Arc::clone(&session),
|
||||||
|
step_context,
|
||||||
|
cancellation_token: CancellationToken::new(),
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
tool_name: codex_tools::ToolName::plain("exec_command"),
|
||||||
|
};
|
||||||
|
let environment = context
|
||||||
|
.step_context
|
||||||
|
.environments
|
||||||
|
.primary()
|
||||||
|
.expect("primary environment");
|
||||||
|
let mut orchestrator = ToolOrchestrator::new();
|
||||||
|
let mut runtime = DeniedOnceRuntime::default();
|
||||||
|
let approve = async {
|
||||||
|
let approval = next_exec_approval(&events).await;
|
||||||
|
assert_eq!(approval.call_id, call_id);
|
||||||
|
assert_eq!(approval.turn_id, action_turn.sub_id);
|
||||||
|
assert_eq!(
|
||||||
|
approval.reason.as_deref(),
|
||||||
|
Some("command failed; retry without sandbox?")
|
||||||
|
);
|
||||||
|
session
|
||||||
|
.notify_approval(call_id, ReviewDecision::Approved)
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (output, ()) = tokio::join!(
|
||||||
|
orchestrator.run(&mut runtime, environment, &context),
|
||||||
|
approve
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output
|
||||||
|
.expect("approved sandbox retry should succeed")
|
||||||
|
.output,
|
||||||
|
"sandbox-retry-succeeded"
|
||||||
|
);
|
||||||
|
assert_eq!(runtime.attempts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn guardian_allows_unified_exec_additional_permissions_requests_past_policy_validation() {
|
async fn guardian_allows_unified_exec_additional_permissions_requests_past_policy_validation() {
|
||||||
let (mut session, mut turn_context_raw) = make_session_and_context().await;
|
let (mut session, mut turn_context_raw) = make_session_and_context().await;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use crate::hook_runtime::run_permission_request_hooks;
|
|||||||
use crate::mcp_tool_call::request_mcp_tool_user_approval;
|
use crate::mcp_tool_call::request_mcp_tool_user_approval;
|
||||||
use crate::sandboxing::SandboxPermissions;
|
use crate::sandboxing::SandboxPermissions;
|
||||||
use crate::session::session::Session;
|
use crate::session::session::Session;
|
||||||
use crate::session::turn_context::TurnContext;
|
|
||||||
use crate::tools::hook_names::HookToolName;
|
use crate::tools::hook_names::HookToolName;
|
||||||
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
|
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
|
||||||
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
|
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
|
||||||
@@ -377,10 +376,6 @@ enum ApprovalReviewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalReviewer {
|
impl ApprovalReviewer {
|
||||||
fn for_turn(turn: &TurnContext) -> Self {
|
|
||||||
Self::for_policy(turn.approval_policy(), turn.config.approvals_reviewer)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn for_policy(approval_policy: AskForApproval, reviewer: ApprovalsReviewer) -> Self {
|
fn for_policy(approval_policy: AskForApproval, reviewer: ApprovalsReviewer) -> Self {
|
||||||
if routes_approval_policy_to_guardian(approval_policy, reviewer) {
|
if routes_approval_policy_to_guardian(approval_policy, reviewer) {
|
||||||
Self::Guardian
|
Self::Guardian
|
||||||
@@ -515,7 +510,10 @@ impl Session {
|
|||||||
{
|
{
|
||||||
ApprovalReviewer::for_policy(*approval_policy, *reviewer)
|
ApprovalReviewer::for_policy(*approval_policy, *reviewer)
|
||||||
} else {
|
} else {
|
||||||
ApprovalReviewer::for_turn(ctx.review_context.turn())
|
ApprovalReviewer::for_policy(
|
||||||
|
ctx.review_context.approval_policy,
|
||||||
|
ctx.review_context.approvals_reviewer,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let decision = match reviewer {
|
let decision = match reviewer {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::session::tests::make_session_and_context_with_rx;
|
||||||
use codex_models_manager::model_info::model_info_from_slug;
|
use codex_models_manager::model_info::model_info_from_slug;
|
||||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
@@ -94,3 +95,60 @@ fn guardian_cwd_rejects_foreign_remote_path() {
|
|||||||
|
|
||||||
assert!(guardian_cwd(codex_exec_server::REMOTE_ENVIRONMENT_ID, cwd).is_err());
|
assert!(guardian_cwd(codex_exec_server::REMOTE_ENVIRONMENT_ID, cwd).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() {
|
||||||
|
let (session, turn, events) = make_session_and_context_with_rx().await;
|
||||||
|
let action = ApprovalAction::McpToolCall {
|
||||||
|
id: "mcp-override".to_string(),
|
||||||
|
server: "example".to_string(),
|
||||||
|
tool_name: "dangerous".to_string(),
|
||||||
|
arguments: None,
|
||||||
|
connector_id: None,
|
||||||
|
connector_name: None,
|
||||||
|
connector_description: None,
|
||||||
|
connected_account_email: None,
|
||||||
|
tool_title: None,
|
||||||
|
tool_description: None,
|
||||||
|
annotations: None,
|
||||||
|
hook_tool_name: HookToolName::new("mcp__example__dangerous"),
|
||||||
|
approval_policy: AskForApproval::OnRequest,
|
||||||
|
reviewer: ApprovalsReviewer::User,
|
||||||
|
approval_mode: AppToolApproval::Prompt,
|
||||||
|
allow_session_remember: false,
|
||||||
|
allow_persistent_approval: false,
|
||||||
|
};
|
||||||
|
let mut review_context = GuardianReviewContext::from(&turn);
|
||||||
|
review_context.approval_policy = AskForApproval::OnRequest;
|
||||||
|
review_context.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||||
|
let context = ApprovalContext {
|
||||||
|
review_context,
|
||||||
|
cancellation_token: None,
|
||||||
|
call_id: "mcp-override".to_string(),
|
||||||
|
tool_name: ToolName::plain("dangerous"),
|
||||||
|
strict_auto_review: false,
|
||||||
|
approval_reason: None,
|
||||||
|
retry_reason: None,
|
||||||
|
network_approval_context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
resolution = session.request_reviewer_approval(action, &context) => {
|
||||||
|
panic!("expected a user approval request, got {resolution:?}");
|
||||||
|
}
|
||||||
|
event = events.recv() => {
|
||||||
|
let codex_protocol::protocol::EventMsg::ElicitationRequest(request) =
|
||||||
|
event.expect("receive user approval request").msg
|
||||||
|
else {
|
||||||
|
panic!("expected an MCP user approval request");
|
||||||
|
};
|
||||||
|
assert_eq!(request.server_name, "example");
|
||||||
|
assert_eq!(
|
||||||
|
request.id,
|
||||||
|
codex_protocol::mcp::RequestId::String(
|
||||||
|
"mcp_tool_call_approval_mcp-override".to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -564,7 +564,7 @@ async fn execute_verified_patch(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| patch_permissions_without_path_matching(&action));
|
.unwrap_or_else(|_| patch_permissions_without_path_matching(&action));
|
||||||
let apply = apply_patch::prepare_apply_patch(
|
let apply = apply_patch::prepare_apply_patch(
|
||||||
tool_ctx.step_context.turn.as_ref(),
|
&tool_ctx.step_context,
|
||||||
&turn_environment,
|
&turn_environment,
|
||||||
&file_system_sandbox_policy,
|
&file_system_sandbox_policy,
|
||||||
action,
|
action,
|
||||||
@@ -595,13 +595,7 @@ async fn execute_verified_patch(
|
|||||||
let mut orchestrator = ToolOrchestrator::new();
|
let mut orchestrator = ToolOrchestrator::new();
|
||||||
let mut runtime = ApplyPatchRuntime::new();
|
let mut runtime = ApplyPatchRuntime::new();
|
||||||
let result = orchestrator
|
let result = orchestrator
|
||||||
.run(
|
.run(&mut runtime, &request, &tool_ctx)
|
||||||
&mut runtime,
|
|
||||||
&request,
|
|
||||||
&tool_ctx,
|
|
||||||
tool_ctx.step_context.turn.as_ref(),
|
|
||||||
tool_ctx.step_context.turn.approval_policy(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map(|result| result.output);
|
.map(|result| result.output);
|
||||||
let (result, delta) = match result {
|
let (result, delta) = match result {
|
||||||
|
|||||||
@@ -276,17 +276,13 @@ impl ExecCommandHandler {
|
|||||||
|
|
||||||
// Sticky turn permissions have already been approved, so they should
|
// Sticky turn permissions have already been approved, so they should
|
||||||
// continue through the normal exec approval flow for the command.
|
// continue through the normal exec approval flow for the command.
|
||||||
|
let approval_policy = context.step_context.settings.approval_policy();
|
||||||
if effective_additional_permissions
|
if effective_additional_permissions
|
||||||
.sandbox_permissions
|
.sandbox_permissions
|
||||||
.requests_sandbox_override()
|
.requests_sandbox_override()
|
||||||
&& !effective_additional_permissions.permissions_preapproved
|
&& !effective_additional_permissions.permissions_preapproved
|
||||||
&& prompt_is_rejected_by_policy(
|
&& prompt_is_rejected_by_policy(approval_policy, /*prompt_is_rule*/ false).is_some()
|
||||||
context.step_context.turn.approval_policy(),
|
|
||||||
/*prompt_is_rule*/ false,
|
|
||||||
)
|
|
||||||
.is_some()
|
|
||||||
{
|
{
|
||||||
let approval_policy = context.step_context.turn.approval_policy();
|
|
||||||
manager.release_process_id(process_id).await;
|
manager.release_process_id(process_id).await;
|
||||||
return Err(FunctionCallError::RespondToModel(format!(
|
return Err(FunctionCallError::RespondToModel(format!(
|
||||||
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
|
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
|
||||||
@@ -302,7 +298,7 @@ impl ExecCommandHandler {
|
|||||||
|| {
|
|| {
|
||||||
normalize_and_validate_additional_permissions(
|
normalize_and_validate_additional_permissions(
|
||||||
additional_permissions_allowed,
|
additional_permissions_allowed,
|
||||||
context.step_context.turn.approval_policy(),
|
approval_policy,
|
||||||
effective_additional_permissions.sandbox_permissions,
|
effective_additional_permissions.sandbox_permissions,
|
||||||
effective_additional_permissions.additional_permissions,
|
effective_additional_permissions.additional_permissions,
|
||||||
effective_additional_permissions.permissions_preapproved,
|
effective_additional_permissions.permissions_preapproved,
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ impl NetworkApprovalService {
|
|||||||
let Some(environment_id) = active_environment_id.or_else(|| {
|
let Some(environment_id) = active_environment_id.or_else(|| {
|
||||||
active_turn
|
active_turn
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|(turn_context, _)| turn_context.environments.primary())
|
.and_then(|(turn_context, _, _)| turn_context.environments.primary())
|
||||||
.map(|environment| environment.selection.environment_id.clone())
|
.map(|environment| environment.selection.environment_id.clone())
|
||||||
}) else {
|
}) else {
|
||||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||||
@@ -648,7 +648,7 @@ impl NetworkApprovalService {
|
|||||||
format!("Network access to \"{target}\" was blocked by policy.");
|
format!("Network access to \"{target}\" was blocked by policy.");
|
||||||
let prompt_reason = format!("{} is not in the allowed_domains", request.host);
|
let prompt_reason = format!("{} is not in the allowed_domains", request.host);
|
||||||
|
|
||||||
let Some((turn_context, strict_auto_review)) = active_turn else {
|
let Some((turn_context, step_settings, strict_auto_review)) = active_turn else {
|
||||||
if let Some(owner_call) = owner_call.as_ref() {
|
if let Some(owner_call) = owner_call.as_ref() {
|
||||||
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
|
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
|
||||||
}
|
}
|
||||||
@@ -694,7 +694,11 @@ impl NetworkApprovalService {
|
|||||||
pending_owner.complete(PendingApprovalDecision::Deny);
|
pending_owner.complete(PendingApprovalDecision::Deny);
|
||||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||||
}
|
}
|
||||||
if !allows_network_approval_flow(turn_context.approval_policy()) {
|
let review_context = GuardianReviewContext::from_resolved_settings(
|
||||||
|
Arc::clone(&turn_context),
|
||||||
|
&step_settings,
|
||||||
|
);
|
||||||
|
if !allows_network_approval_flow(review_context.approval_policy) {
|
||||||
if let Some(owner_call) = owner_call.as_ref() {
|
if let Some(owner_call) = owner_call.as_ref() {
|
||||||
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
|
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
|
||||||
}
|
}
|
||||||
@@ -755,7 +759,7 @@ impl NetworkApprovalService {
|
|||||||
cwd,
|
cwd,
|
||||||
};
|
};
|
||||||
let approval_context = ApprovalContext {
|
let approval_context = ApprovalContext {
|
||||||
review_context: GuardianReviewContext::from(&turn_context),
|
review_context,
|
||||||
cancellation_token: None,
|
cancellation_token: None,
|
||||||
call_id: approval_call_id,
|
call_id: approval_call_id,
|
||||||
tool_name: telemetry_tool_name.clone(),
|
tool_name: telemetry_tool_name.clone(),
|
||||||
|
|||||||
@@ -127,12 +127,12 @@ impl ToolOrchestrator {
|
|||||||
tool: &mut T,
|
tool: &mut T,
|
||||||
req: &Rq,
|
req: &Rq,
|
||||||
tool_ctx: &ToolCtx,
|
tool_ctx: &ToolCtx,
|
||||||
turn_ctx: &crate::session::turn_context::TurnContext,
|
|
||||||
approval_policy: AskForApproval,
|
|
||||||
) -> Result<OrchestratorRunResult<Out>, ToolError>
|
) -> Result<OrchestratorRunResult<Out>, ToolError>
|
||||||
where
|
where
|
||||||
T: ToolRuntime<Rq, Out>,
|
T: ToolRuntime<Rq, Out>,
|
||||||
{
|
{
|
||||||
|
let turn_ctx = tool_ctx.step_context.turn.as_ref();
|
||||||
|
let approval_policy = tool_ctx.step_context.settings.approval_policy();
|
||||||
let otel = turn_ctx.session_telemetry.clone();
|
let otel = turn_ctx.session_telemetry.clone();
|
||||||
let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
|
let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
|
||||||
let otel_ci = &tool_ctx.call_id;
|
let otel_ci = &tool_ctx.call_id;
|
||||||
@@ -140,7 +140,7 @@ impl ToolOrchestrator {
|
|||||||
.session
|
.session
|
||||||
.active_turn_context_and_strict_auto_review()
|
.active_turn_context_and_strict_auto_review()
|
||||||
.await
|
.await
|
||||||
.is_some_and(|(_, strict_auto_review)| strict_auto_review);
|
.is_some_and(|(_, _, strict_auto_review)| strict_auto_review);
|
||||||
// 1) Approval
|
// 1) Approval
|
||||||
let mut already_approved = false;
|
let mut already_approved = false;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::exec::ExecCapturePolicy;
|
|||||||
use crate::exec::ExecExpiration;
|
use crate::exec::ExecExpiration;
|
||||||
use crate::guardian::GUARDIAN_REVIEW_TIMEOUT;
|
use crate::guardian::GUARDIAN_REVIEW_TIMEOUT;
|
||||||
use crate::guardian::GuardianNetworkAccessTrigger;
|
use crate::guardian::GuardianNetworkAccessTrigger;
|
||||||
use crate::guardian::routes_approval_to_guardian;
|
use crate::guardian::routes_approval_policy_to_guardian;
|
||||||
use crate::plugins::metrics::sidecar_for_command;
|
use crate::plugins::metrics::sidecar_for_command;
|
||||||
use crate::sandboxing::ExecOptions;
|
use crate::sandboxing::ExecOptions;
|
||||||
use crate::sandboxing::ExecServerEnvConfig;
|
use crate::sandboxing::ExecServerEnvConfig;
|
||||||
@@ -289,8 +289,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
|||||||
let mut launch = network.remote_launch_config().await.map_err(|err| {
|
let mut launch = network.remote_launch_config().await.map_err(|err| {
|
||||||
ToolError::Codex(CodexErr::Io(io::Error::other(err.to_string())))
|
ToolError::Codex(CodexErr::Io(io::Error::other(err.to_string())))
|
||||||
})?;
|
})?;
|
||||||
if routes_approval_to_guardian(&ctx.step_context.turn)
|
if routes_approval_policy_to_guardian(
|
||||||
&& network.remote_policy_decider().is_some()
|
ctx.step_context.settings.approval_policy(),
|
||||||
|
ctx.step_context.settings.approvals_reviewer(),
|
||||||
|
) && network.remote_policy_decider().is_some()
|
||||||
{
|
{
|
||||||
let timeout = ctx
|
let timeout = ctx
|
||||||
.session
|
.session
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
|||||||
environment_id: req.turn_environment.selection.environment_id.clone(),
|
environment_id: req.turn_environment.selection.environment_id.clone(),
|
||||||
source: GuardianCommandSource::UnifiedExec,
|
source: GuardianCommandSource::UnifiedExec,
|
||||||
tool_name: ctx.tool_name.clone(),
|
tool_name: ctx.tool_name.clone(),
|
||||||
approval_policy: ctx.step_context.turn.approval_policy(),
|
approval_policy: ctx.step_context.settings.approval_policy(),
|
||||||
permission_profile: exec_request.permission_profile.clone(),
|
permission_profile: exec_request.permission_profile.clone(),
|
||||||
sandbox_permissions: req.sandbox_permissions,
|
sandbox_permissions: req.sandbox_permissions,
|
||||||
approval_sandbox_permissions: approval_sandbox_permissions(
|
approval_sandbox_permissions: approval_sandbox_permissions(
|
||||||
@@ -280,7 +280,7 @@ impl CoreShellActionProvider {
|
|||||||
};
|
};
|
||||||
match stopwatch
|
match stopwatch
|
||||||
.pause_for(async {
|
.pause_for(async {
|
||||||
let (turn_context, strict_auto_review) = self
|
let (turn_context, step_settings, strict_auto_review) = self
|
||||||
.session
|
.session
|
||||||
.active_turn_context_and_strict_auto_review()
|
.active_turn_context_and_strict_auto_review()
|
||||||
.await
|
.await
|
||||||
@@ -291,7 +291,10 @@ impl CoreShellActionProvider {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let approval_ctx = ApprovalContext {
|
let approval_ctx = ApprovalContext {
|
||||||
review_context: GuardianReviewContext::from(turn_context),
|
review_context: GuardianReviewContext::from_resolved_settings(
|
||||||
|
turn_context,
|
||||||
|
&step_settings,
|
||||||
|
),
|
||||||
// The running process can outlive its launching tool or code-mode cell.
|
// The running process can outlive its launching tool or code-mode cell.
|
||||||
cancellation_token: None,
|
cancellation_token: None,
|
||||||
call_id: self.call_id.clone(),
|
call_id: self.call_id.clone(),
|
||||||
|
|||||||
@@ -1266,7 +1266,7 @@ impl UnifiedExecProcessManager {
|
|||||||
.create_exec_approval_requirement_for_shell(
|
.create_exec_approval_requirement_for_shell(
|
||||||
ExecApprovalRequest {
|
ExecApprovalRequest {
|
||||||
command: &request.command,
|
command: &request.command,
|
||||||
approval_policy: turn.approval_policy(),
|
approval_policy: context.step_context.settings.approval_policy(),
|
||||||
permission_profile: request.turn_environment.permission_profile().clone(),
|
permission_profile: request.turn_environment.permission_profile().clone(),
|
||||||
environment_policy: request.turn_environment.config().exec_policy.as_ref(),
|
environment_policy: request.turn_environment.config().exec_policy.as_ref(),
|
||||||
windows_sandbox_level: request.turn_environment.config().windows_sandbox_level,
|
windows_sandbox_level: request.turn_environment.config().windows_sandbox_level,
|
||||||
@@ -1311,7 +1311,7 @@ impl UnifiedExecProcessManager {
|
|||||||
tool_name: ToolName::plain("exec_command"),
|
tool_name: ToolName::plain("exec_command"),
|
||||||
};
|
};
|
||||||
orchestrator
|
orchestrator
|
||||||
.run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy())
|
.run(&mut runtime, &req, &tool_ctx)
|
||||||
.await
|
.await
|
||||||
.map(|result| (result.output, result.deferred_network_approval))
|
.map(|result| (result.output, result.deferred_network_approval))
|
||||||
.map_err(|err| match err {
|
.map_err(|err| match err {
|
||||||
|
|||||||
Reference in new issue
Block a user