mirror of
https://github.com/openai/codex.git
synced 2026-09-09 15:58:47 +00:00
Refactor permission request approval flow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -2,7 +2,6 @@ use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
use codex_hooks::PermissionRequestGuardianReview;
|
||||
use codex_hooks::PermissionRequestOutcome;
|
||||
use codex_hooks::PermissionRequestRequest;
|
||||
use codex_hooks::PostToolUseOutcome;
|
||||
@@ -14,10 +13,8 @@ use codex_hooks::UserPromptSubmitOutcome;
|
||||
use codex_hooks::UserPromptSubmitRequest;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
@@ -29,6 +26,8 @@ use serde_json::Value;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::tools::sandboxing::PermissionRequestHookRequest;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
|
||||
pub(crate) struct HookRuntimeOutcome {
|
||||
pub should_stop: bool,
|
||||
@@ -154,14 +153,20 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
pub(crate) async fn run_permission_request_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
run_id_suffix: String,
|
||||
tool_name: String,
|
||||
command: String,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
justification: Option<String>,
|
||||
guardian_review: Option<PermissionRequestGuardianReview>,
|
||||
hook_request: PermissionRequestHookRequest,
|
||||
) -> Option<PermissionRequestDecision> {
|
||||
let PermissionRequestHookRequest {
|
||||
run_id_suffix,
|
||||
payload,
|
||||
guardian_review,
|
||||
} = hook_request;
|
||||
let PermissionRequestPayload {
|
||||
tool_name,
|
||||
command,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
justification,
|
||||
} = payload;
|
||||
let request = PermissionRequestRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::tools::network_approval::finish_deferred_network_approval;
|
||||
use crate::tools::network_approval::finish_immediate_network_approval;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::PermissionRequestHookRequest;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::SandboxOverride;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
@@ -60,6 +61,19 @@ enum ApprovalAttempt {
|
||||
Retry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ApprovalTelemetry<'a> {
|
||||
otel: &'a SessionTelemetry,
|
||||
tool_name: &'a str,
|
||||
call_id: &'a str,
|
||||
}
|
||||
|
||||
struct ApprovalRequestCtx<'a> {
|
||||
approval: ApprovalCtx<'a>,
|
||||
routes_to_guardian: bool,
|
||||
telemetry: ApprovalTelemetry<'a>,
|
||||
}
|
||||
|
||||
impl ToolOrchestrator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -164,11 +178,15 @@ impl ToolOrchestrator {
|
||||
tool,
|
||||
req,
|
||||
ApprovalAttempt::Initial,
|
||||
approval_ctx,
|
||||
turn_ctx,
|
||||
&otel,
|
||||
otel_tn,
|
||||
otel_ci,
|
||||
ApprovalRequestCtx {
|
||||
routes_to_guardian: use_guardian,
|
||||
approval: approval_ctx,
|
||||
telemetry: ApprovalTelemetry {
|
||||
otel: &otel,
|
||||
tool_name: otel_tn,
|
||||
call_id: otel_ci,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -325,11 +343,15 @@ impl ToolOrchestrator {
|
||||
tool,
|
||||
req,
|
||||
ApprovalAttempt::Retry,
|
||||
approval_ctx,
|
||||
turn_ctx,
|
||||
&otel,
|
||||
otel_tn,
|
||||
otel_ci,
|
||||
ApprovalRequestCtx {
|
||||
routes_to_guardian: use_guardian,
|
||||
approval: approval_ctx,
|
||||
telemetry: ApprovalTelemetry {
|
||||
otel: &otel,
|
||||
tool_name: otel_tn,
|
||||
call_id: otel_ci,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -405,31 +427,40 @@ impl ToolOrchestrator {
|
||||
tool: &mut T,
|
||||
req: &Rq,
|
||||
approval_attempt: ApprovalAttempt,
|
||||
approval_ctx: ApprovalCtx<'_>,
|
||||
turn_ctx: &crate::codex::TurnContext,
|
||||
otel: &SessionTelemetry,
|
||||
otel_tn: &str,
|
||||
otel_ci: &str,
|
||||
request_ctx: ApprovalRequestCtx<'_>,
|
||||
) -> Result<ReviewDecision, ToolError>
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
let guardian_review = if routes_approval_to_guardian(turn_ctx) {
|
||||
match tool.guardian_approval_request(req, &approval_ctx) {
|
||||
Some(request) => Some(
|
||||
review_approval_request_with_review(
|
||||
approval_ctx.session,
|
||||
approval_ctx.turn,
|
||||
approval_ctx
|
||||
.guardian_review_id
|
||||
.clone()
|
||||
.expect("guardian review id should be present for guardian approvals"),
|
||||
request,
|
||||
approval_ctx.retry_reason.clone(),
|
||||
let ApprovalRequestCtx {
|
||||
approval: approval_ctx,
|
||||
routes_to_guardian,
|
||||
telemetry,
|
||||
} = request_ctx;
|
||||
let ApprovalTelemetry {
|
||||
otel,
|
||||
tool_name: otel_tn,
|
||||
call_id: otel_ci,
|
||||
} = telemetry;
|
||||
|
||||
let guardian_review = if routes_to_guardian {
|
||||
if let Some(review_id) = approval_ctx.guardian_review_id.clone() {
|
||||
if let Some(request) = tool.guardian_approval_request(req, &approval_ctx) {
|
||||
Some(
|
||||
review_approval_request_with_review(
|
||||
approval_ctx.session,
|
||||
approval_ctx.turn,
|
||||
review_id,
|
||||
request,
|
||||
approval_ctx.retry_reason.clone(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
None => None,
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -443,15 +474,13 @@ impl ToolOrchestrator {
|
||||
match run_permission_request_hooks(
|
||||
approval_ctx.session,
|
||||
approval_ctx.turn,
|
||||
run_id_suffix,
|
||||
permission_request.tool_name,
|
||||
permission_request.command,
|
||||
permission_request.sandbox_permissions,
|
||||
permission_request.additional_permissions,
|
||||
permission_request.justification,
|
||||
guardian_review
|
||||
.as_ref()
|
||||
.map(|review| permission_request_guardian_review(review.review.clone())),
|
||||
PermissionRequestHookRequest {
|
||||
run_id_suffix,
|
||||
payload: permission_request,
|
||||
guardian_review: guardian_review
|
||||
.as_ref()
|
||||
.map(|review| permission_request_guardian_review(review.review.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -488,7 +517,7 @@ impl ToolOrchestrator {
|
||||
}
|
||||
|
||||
let decision = tool.start_approval_async(req, approval_ctx).await;
|
||||
let otel_source = if routes_approval_to_guardian(turn_ctx) {
|
||||
let otel_source = if routes_to_guardian {
|
||||
ToolDecisionSource::AutomatedReviewer
|
||||
} else {
|
||||
ToolDecisionSource::User
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::state::SessionServices;
|
||||
use crate::tools::network_approval::NetworkApprovalSpec;
|
||||
use codex_hooks::PermissionRequestGuardianReview;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
@@ -159,6 +160,13 @@ impl PermissionRequestPayload {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PermissionRequestHookRequest {
|
||||
pub run_id_suffix: String,
|
||||
pub payload: PermissionRequestPayload,
|
||||
pub guardian_review: Option<PermissionRequestGuardianReview>,
|
||||
}
|
||||
|
||||
// Specifies what tool orchestrator should do with a given tool call.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExecApprovalRequirement {
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -579,10 +580,9 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
if let Err(error) = config.features.enable(Feature::CodexHooks) {
|
||||
panic!("test config should allow feature update: {error}");
|
||||
}
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -678,10 +678,9 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
if let Err(error) = config.features.enable(Feature::CodexHooks) {
|
||||
panic!("test config should allow feature update: {error}");
|
||||
}
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -1096,8 +1095,10 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "permissionrequest-shell-command";
|
||||
let marker = std::env::temp_dir().join("permissionrequest-shell-command-marker");
|
||||
let command = format!("printf allowed > {}", marker.display());
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let command = format!("rm -f {}", marker.display());
|
||||
let args = serde_json::json!({
|
||||
"command": ["rm", "-f", marker.display().to_string()],
|
||||
});
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
@@ -1105,7 +1106,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
"shell",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
@@ -1138,31 +1139,21 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover permission request marker")?;
|
||||
}
|
||||
fs::write(&marker, "seed").context("create permission request marker")?;
|
||||
|
||||
test.submit_turn_with_policies(
|
||||
"run the shell command after hook approval",
|
||||
AskForApproval::OnRequest,
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
requests[1].function_call_output(call_id);
|
||||
assert!(
|
||||
output.contains("allowed"),
|
||||
"shell command output should reach the model after hook approval",
|
||||
);
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"approved command should create marker file"
|
||||
!marker.exists(),
|
||||
"approved command should remove marker file"
|
||||
);
|
||||
|
||||
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
|
||||
@@ -1184,20 +1175,37 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permission_request_hook_receives_guardian_review_before_fallback() -> Result<()> {
|
||||
#[test]
|
||||
fn permission_request_hook_receives_guardian_review_before_fallback() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build current-thread tokio runtime")
|
||||
.block_on(permission_request_hook_receives_guardian_review_before_fallback_inner())
|
||||
})
|
||||
.expect("spawn guardian permission request hook test thread")
|
||||
.join()
|
||||
.expect("guardian permission request hook test thread panicked")
|
||||
}
|
||||
|
||||
async fn permission_request_hook_receives_guardian_review_before_fallback_inner() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "permissionrequest-guardian-review";
|
||||
let marker = std::env::temp_dir().join("permissionrequest-guardian-review-marker");
|
||||
let command = format!("printf guardian > {}", marker.display());
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let command = format!("rm -f {}", marker.display());
|
||||
let args = serde_json::json!({
|
||||
"command": ["rm", "-f", marker.display().to_string()],
|
||||
});
|
||||
let guardian_assessment = serde_json::json!({
|
||||
"risk_level": "medium",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "The user asked to run this local marker command.",
|
||||
"rationale": "The user asked to delete this local marker file.",
|
||||
})
|
||||
.to_string();
|
||||
let responses = mount_sse_sequence(
|
||||
@@ -1207,7 +1215,7 @@ async fn permission_request_hook_receives_guardian_review_before_fallback() -> R
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
"shell",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
@@ -1235,26 +1243,25 @@ async fn permission_request_hook_receives_guardian_review_before_fallback() -> R
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::GuardianApproval)
|
||||
.expect("test config should allow feature update");
|
||||
if let Err(error) = config.features.enable(Feature::CodexHooks) {
|
||||
panic!("test config should allow feature update: {error}");
|
||||
}
|
||||
if let Err(error) = config.features.enable(Feature::ExecPermissionApprovals) {
|
||||
panic!("test config should allow feature update: {error}");
|
||||
}
|
||||
if let Err(error) = config.features.enable(Feature::GuardianApproval) {
|
||||
panic!("test config should allow feature update: {error}");
|
||||
}
|
||||
config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover guardian review marker")?;
|
||||
}
|
||||
fs::write(&marker, "seed").context("create guardian review marker")?;
|
||||
|
||||
test.submit_turn_with_policies(
|
||||
"run the shell command after guardian review",
|
||||
AskForApproval::OnRequest,
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1265,12 +1272,20 @@ async fn permission_request_hook_receives_guardian_review_before_fallback() -> R
|
||||
"guardian decision should be reused instead of running a second review",
|
||||
);
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"guardian-approved fallback should create marker file",
|
||||
requests[2]
|
||||
.input()
|
||||
.iter()
|
||||
.any(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output")),
|
||||
"guardian-approved fallback should continue with tool output",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"guardian-approved fallback should remove marker file",
|
||||
);
|
||||
|
||||
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["guardian_review"],
|
||||
serde_json::json!({
|
||||
@@ -1278,7 +1293,7 @@ async fn permission_request_hook_receives_guardian_review_before_fallback() -> R
|
||||
"decision": "allow",
|
||||
"risk_level": "medium",
|
||||
"user_authorization": "high",
|
||||
"rationale": "The user asked to run this local marker command.",
|
||||
"rationale": "The user asked to delete this local marker file.",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user