diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d3aa5263d..9119837fa 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -567,6 +567,7 @@ pub(crate) fn codex_hook_run_metadata( fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { match event_name { HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 63bfc1cd5..8591e970a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1407,6 +1407,7 @@ "HookEventName": { "enum": [ "preToolUse", + "permissionRequest", "postToolUse", "sessionStart", "userPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 983dce5af..059eb5e11 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8509,6 +8509,7 @@ "HookEventName": { "enum": [ "preToolUse", + "permissionRequest", "postToolUse", "sessionStart", "userPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 072fff49f..56986d5ab 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5217,6 +5217,7 @@ "HookEventName": { "enum": [ "preToolUse", + "permissionRequest", "postToolUse", "sessionStart", "userPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 4f444961a..a4d378649 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -8,6 +8,7 @@ "HookEventName": { "enum": [ "preToolUse", + "permissionRequest", "postToolUse", "sessionStart", "userPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 6d439c95c..ac77d6163 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -8,6 +8,7 @@ "HookEventName": { "enum": [ "preToolUse", + "permissionRequest", "postToolUse", "sessionStart", "userPromptSubmit", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b97c709b9..28657d228 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index eea9f9444..452f0b5fb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -381,7 +381,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop } ); diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 53fa99a88..bca524695 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -4,6 +4,9 @@ use std::time::Duration; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; +use codex_hooks::PermissionRequestDecision; +use codex_hooks::PermissionRequestOutcome; +use codex_hooks::PermissionRequestRequest; use codex_hooks::PostToolUseOutcome; use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; @@ -31,6 +34,7 @@ use serde_json::Value; use crate::codex::Session; use crate::codex::TurnContext; use crate::event_mapping::parse_turn_item; +use crate::tools::sandboxing::PermissionRequestPayload; pub(crate) struct HookRuntimeOutcome { pub should_stop: bool, @@ -153,6 +157,39 @@ pub(crate) async fn run_pre_tool_use_hooks( if should_block { block_reason } else { None } } +// PermissionRequest hooks share the same preview/start/completed event flow as +// other hook types, but they return an optional decision instead of mutating +// tool input or post-run state. +pub(crate) async fn run_permission_request_hooks( + sess: &Arc, + turn_context: &Arc, + run_id_suffix: &str, + payload: PermissionRequestPayload, +) -> Option { + let request = PermissionRequestRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: payload.tool_name, + run_id_suffix: run_id_suffix.to_string(), + command: payload.command, + description: payload.description, + }; + let preview_runs = sess.hooks().preview_permission_request(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let PermissionRequestOutcome { + hook_events, + decision, + } = sess.hooks().run_permission_request(request).await; + emit_hook_completed_events(sess, turn_context, hook_events).await; + + decision +} + pub(crate) async fn run_post_tool_use_hooks( sess: &Arc, turn_context: &Arc, @@ -390,6 +427,7 @@ fn hook_run_analytics_payload( fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); 3] { let hook_name = match run.event_name { HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 9aa700cd4..f2224438f 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -77,6 +77,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { struct RunExecLikeArgs { tool_name: String, exec_params: ExecParams, + hook_command: String, additional_permissions: Option, prefix_rule: Option>, session: Arc, @@ -241,6 +242,7 @@ impl ToolHandler for ShellHandler { Self::run_exec_like(RunExecLikeArgs { tool_name: tool_name.display(), exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), additional_permissions: params.additional_permissions.clone(), prefix_rule, session, @@ -258,6 +260,7 @@ impl ToolHandler for ShellHandler { Self::run_exec_like(RunExecLikeArgs { tool_name: tool_name.display(), exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), additional_permissions: None, prefix_rule: None, session, @@ -366,6 +369,7 @@ impl ToolHandler for ShellCommandHandler { ShellHandler::run_exec_like(RunExecLikeArgs { tool_name: tool_name.display(), exec_params, + hook_command: params.command, additional_permissions: params.additional_permissions.clone(), prefix_rule, session, @@ -384,6 +388,7 @@ impl ShellHandler { let RunExecLikeArgs { tool_name, exec_params, + hook_command, additional_permissions, prefix_rule, session, @@ -514,6 +519,7 @@ impl ShellHandler { let req = ShellRequest { command: exec_params.command.clone(), + hook_command, cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f2604a57d..ab5279d1b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -317,6 +317,7 @@ impl ToolHandler for UnifiedExecHandler { .exec_command( ExecCommandRequest { command, + hook_command: args.cmd, process_id, yield_time_ms, max_output_tokens, diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index de689ea25..491b1b020 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -5,8 +5,11 @@ use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; +use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::denied_network_policy_message; +use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::ToolError; +use codex_hooks::PermissionRequestDecision; use codex_network_proxy::BlockedRequest; use codex_network_proxy::BlockedRequestObserver; use codex_network_proxy::NetworkDecision; @@ -43,6 +46,7 @@ pub(crate) enum NetworkApprovalMode { pub(crate) struct NetworkApprovalSpec { pub network: Option, pub mode: NetworkApprovalMode, + pub command: String, } #[derive(Clone, Debug)] @@ -172,6 +176,7 @@ impl PendingHostApproval { struct ActiveNetworkApprovalCall { registration_id: String, turn_id: String, + command: String, } pub(crate) struct NetworkApprovalService { @@ -204,7 +209,7 @@ impl NetworkApprovalService { other_approved_hosts.extend(approved_hosts.iter().cloned()); } - async fn register_call(&self, registration_id: String, turn_id: String) { + async fn register_call(&self, registration_id: String, turn_id: String, command: String) { let mut active_calls = self.active_calls.lock().await; let key = registration_id.clone(); active_calls.insert( @@ -212,6 +217,7 @@ impl NetworkApprovalService { Arc::new(ActiveNetworkApprovalCall { registration_id, turn_id, + command, }), ); } @@ -371,6 +377,46 @@ impl NetworkApprovalService { }; let owner_call = self.resolve_single_active_call().await; let guardian_approval_id = Self::approval_id_for_key(&key); + let prompt_command = vec!["network-access".to_string(), target.clone()]; + let command = owner_call + .as_ref() + .map_or_else(|| prompt_command.join(" "), |call| call.command.clone()); + if let Some(permission_request_decision) = run_permission_request_hooks( + &session, + &turn_context, + &guardian_approval_id, + PermissionRequestPayload { + tool_name: "Bash".to_string(), + command, + description: Some(format!("network-access {target}")), + }, + ) + .await + { + match permission_request_decision { + PermissionRequestDecision::Allow => { + pending + .set_decision(PendingApprovalDecision::AllowOnce) + .await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + return NetworkDecision::Allow; + } + PermissionRequestDecision::Deny { message } => { + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(message), + ) + .await; + } + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } + } + } let use_guardian = routes_approval_to_guardian(&turn_context); let guardian_review_id = use_guardian.then(new_guardian_review_id); let approval_decision = if let Some(review_id) = guardian_review_id.clone() { @@ -392,13 +438,11 @@ impl NetworkApprovalService { ) .await } else { - let approval_id = Self::approval_id_for_key(&key); - let prompt_command = vec!["network-access".to_string(), target.clone()]; let available_decisions = None; session .request_command_approval( turn_context.as_ref(), - approval_id, + guardian_approval_id, /*approval_id*/ None, prompt_command, turn_context.cwd.clone(), @@ -590,7 +634,7 @@ pub(crate) async fn begin_network_approval( session .services .network_approval - .register_call(registration_id.clone(), turn_id.to_string()) + .register_call(registration_id.clone(), turn_id.to_string(), spec.command) .await; Some(ActiveNetworkApproval { diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index ca777e7cd..888d0bc35 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -211,7 +211,11 @@ fn denied_blocked_request(host: &str) -> BlockedRequest { async fn record_blocked_request_sets_policy_outcome_for_owner_call() { let service = NetworkApprovalService::default(); service - .register_call("registration-1".to_string(), "turn-1".to_string()) + .register_call( + "registration-1".to_string(), + "turn-1".to_string(), + "curl http://example.com".to_string(), + ) .await; service @@ -230,7 +234,11 @@ async fn record_blocked_request_sets_policy_outcome_for_owner_call() { async fn blocked_request_policy_does_not_override_user_denial_outcome() { let service = NetworkApprovalService::default(); service - .register_call("registration-1".to_string(), "turn-1".to_string()) + .register_call( + "registration-1".to_string(), + "turn-1".to_string(), + "curl http://example.com".to_string(), + ) .await; service @@ -250,10 +258,18 @@ async fn blocked_request_policy_does_not_override_user_denial_outcome() { async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() { let service = NetworkApprovalService::default(); service - .register_call("registration-1".to_string(), "turn-1".to_string()) + .register_call( + "registration-1".to_string(), + "turn-1".to_string(), + "curl http://example.com".to_string(), + ) .await; service - .register_call("registration-2".to_string(), "turn-1".to_string()) + .register_call( + "registration-2".to_string(), + "turn-1".to_string(), + "gh api /foo".to_string(), + ) .await; service diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index f9b2a6496..b96e1a1a7 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -10,6 +10,7 @@ use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; +use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::NetworkApprovalMode; @@ -24,6 +25,7 @@ use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::default_exec_approval_requirement; +use codex_hooks::PermissionRequestDecision; use codex_otel::ToolDecisionSource; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -114,9 +116,6 @@ impl ToolOrchestrator { let otel = turn_ctx.session_telemetry.clone(); let otel_tn = &tool_ctx.tool_name; let otel_ci = &tool_ctx.call_id; - let otel_user = ToolDecisionSource::User; - let otel_automated_reviewer = ToolDecisionSource::AutomatedReviewer; - let otel_cfg = ToolDecisionSource::Config; let use_guardian = routes_approval_to_guardian(turn_ctx); // 1) Approval @@ -127,7 +126,12 @@ impl ToolOrchestrator { }); match requirement { ExecApprovalRequirement::Skip { .. } => { - otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg); + otel.tool_decision( + otel_tn, + otel_ci, + &ReviewDecision::Approved, + ToolDecisionSource::Config, + ); } ExecApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason)); @@ -142,14 +146,16 @@ impl ToolOrchestrator { retry_reason: reason, network_approval_context: None, }; - let decision = tool.start_approval_async(req, approval_ctx).await; - let otel_source = if use_guardian { - otel_automated_reviewer.clone() - } else { - otel_user.clone() - }; - - otel.tool_decision(otel_tn, otel_ci, &decision, otel_source); + let decision = Self::request_approval( + tool, + req, + tool_ctx.call_id.as_str(), + approval_ctx, + tool_ctx, + use_guardian, + &otel, + ) + .await?; match decision { ReviewDecision::Denied | ReviewDecision::Abort => { @@ -296,13 +302,17 @@ impl ToolOrchestrator { network_approval_context: network_approval_context.clone(), }; - let decision = tool.start_approval_async(req, approval_ctx).await; - let otel_source = if use_guardian { - otel_automated_reviewer - } else { - otel_user - }; - otel.tool_decision(otel_tn, otel_ci, &decision, otel_source); + let permission_request_run_id = format!("{}:retry", tool_ctx.call_id); + let decision = Self::request_approval( + tool, + req, + &permission_request_run_id, + approval_ctx, + tool_ctx, + use_guardian, + &otel, + ) + .await?; match decision { ReviewDecision::Denied | ReviewDecision::Abort => { @@ -365,6 +375,69 @@ impl ToolOrchestrator { Err(err) => Err(err), } } + + // PermissionRequest hooks take top precedence for answering approval + // prompts. If no matching hook returns a decision, fall back to the + // normal guardian or user approval path. + async fn request_approval( + tool: &mut T, + req: &Rq, + permission_request_run_id: &str, + approval_ctx: ApprovalCtx<'_>, + tool_ctx: &ToolCtx, + use_guardian: bool, + otel: &codex_otel::SessionTelemetry, + ) -> Result + where + T: ToolRuntime, + { + if let Some(permission_request) = tool.permission_request_payload(req) { + match run_permission_request_hooks( + approval_ctx.session, + approval_ctx.turn, + permission_request_run_id, + permission_request, + ) + .await + { + Some(PermissionRequestDecision::Allow) => { + let decision = ReviewDecision::Approved; + otel.tool_decision( + &tool_ctx.tool_name, + &tool_ctx.call_id, + &decision, + ToolDecisionSource::Config, + ); + return Ok(decision); + } + Some(PermissionRequestDecision::Deny { message }) => { + let decision = ReviewDecision::Denied; + otel.tool_decision( + &tool_ctx.tool_name, + &tool_ctx.call_id, + &decision, + ToolDecisionSource::Config, + ); + return Err(ToolError::Rejected(message)); + } + None => {} + } + } + + let decision = tool.start_approval_async(req, approval_ctx).await; + let otel_source = if use_guardian { + ToolDecisionSource::AutomatedReviewer + } else { + ToolDecisionSource::User + }; + otel.tool_decision( + &tool_ctx.tool_name, + &tool_ctx.call_id, + &decision, + otel_source, + ); + Ok(decision) + } } fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 203553bf8..21bf0e64c 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -23,6 +23,7 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; +use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; @@ -44,6 +45,7 @@ use std::collections::HashMap; #[derive(Clone, Debug)] pub struct ShellRequest { pub command: Vec, + pub hook_command: String, pub cwd: AbsolutePathBuf, pub timeout_ms: Option, pub env: HashMap, @@ -197,6 +199,14 @@ impl Approvable for ShellRuntime { Some(req.exec_approval_requirement.clone()) } + fn permission_request_payload(&self, req: &ShellRequest) -> Option { + Some(PermissionRequestPayload { + tool_name: "Bash".to_string(), + command: req.hook_command.clone(), + description: req.justification.clone(), + }) + } + fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } @@ -212,6 +222,7 @@ impl ToolRuntime for ShellRuntime { Some(NetworkApprovalSpec { network: req.network.clone(), mode: NetworkApprovalMode::Immediate, + command: req.hook_command.clone(), }) } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index dd1e71410..18813cfc8 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -8,11 +8,13 @@ use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; +use crate::hook_runtime::run_permission_request_hooks; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::tools::runtimes::build_sandbox_command; +use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; @@ -22,6 +24,7 @@ use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_features::Feature; +use codex_hooks::PermissionRequestDecision; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -321,6 +324,7 @@ enum DecisionSource { struct PromptDecision { decision: ReviewDecision, guardian_review_id: Option, + rejection_message: Option, } fn execve_prompt_is_rejected_by_policy( @@ -395,11 +399,44 @@ impl CoreShellActionProvider { let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id); Ok(stopwatch .pause_for(async move { + // 1) Run PermissionRequest hooks + let permission_request = PermissionRequestPayload { + tool_name: "Bash".to_string(), + command: codex_shell_command::parse_command::shlex_join(&command), + description: None, + }; + let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); + match run_permission_request_hooks( + &session, + &turn, + &effective_approval_id, + permission_request, + ) + .await + { + Some(PermissionRequestDecision::Allow) => { + return PromptDecision { + decision: ReviewDecision::Approved, + guardian_review_id: None, + rejection_message: None, + }; + } + Some(PermissionRequestDecision::Deny { message }) => { + return PromptDecision { + decision: ReviewDecision::Denied, + guardian_review_id: None, + rejection_message: Some(message), + }; + } + None => {} + } + + // 2) Route to Guardian if configured if let Some(review_id) = guardian_review_id.clone() { let decision = review_approval_request( &session, &turn, - review_id, + review_id.clone(), GuardianApprovalRequest::Execve { id: call_id.clone(), source, @@ -414,8 +451,11 @@ impl CoreShellActionProvider { return PromptDecision { decision, guardian_review_id, + rejection_message: None, }; } + + // 3) Fall back to regular user prompt let decision = session .request_command_approval( &turn, @@ -433,6 +473,7 @@ impl CoreShellActionProvider { PromptDecision { decision, guardian_review_id: None, + rejection_message: None, } }) .await) @@ -488,7 +529,11 @@ impl CoreShellActionProvider { } }, ReviewDecision::Denied => { - let message = if let Some(review_id) = + let message = if let Some(message) = + prompt_decision.rejection_message.clone() + { + message + } else if let Some(review_id) = prompt_decision.guardian_review_id.as_deref() { guardian_rejection_message(self.session.as_ref(), review_id).await diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index f11050f72..66439e461 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -6,11 +6,16 @@ use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; use super::map_exec_result; +use crate::codex::make_session_and_context; +use crate::config::Constrained; use crate::sandboxing::SandboxPermissions; +use anyhow::Context; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; +use codex_hooks::Hooks; +use codex_hooks::HooksConfig; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -21,6 +26,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; @@ -30,8 +36,10 @@ use codex_shell_escalation::ExecResult; use codex_shell_escalation::Permissions as EscalatedPermissions; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::PathBuf; use std::time::Duration; +use tokio::sync::RwLock; fn host_absolute_path(segments: &[&str]) -> String { let mut path = if cfg!(windows) { @@ -319,6 +327,134 @@ fn shell_request_escalation_execution_is_explicit() { ); } +#[tokio::test(flavor = "current_thread")] +async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { + let (mut session, mut turn_context) = make_session_and_context().await; + std::fs::create_dir_all(&turn_context.config.codex_home) + .context("recreate codex home for hook fixtures")?; + let script_path = turn_context + .config + .codex_home + .join("permission_request_hook.py"); + let log_path = turn_context + .config + .codex_home + .join("permission_request_hook_log.jsonl"); + std::fs::write( + &script_path, + format!( + "#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' '{response}'\n", + log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?, + response = "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}", + ), + ) + .with_context(|| format!("write hook script to {}", script_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(&script_path) + .with_context(|| format!("read hook script metadata from {}", script_path.display()))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script_path, permissions) + .with_context(|| format!("set hook script permissions on {}", script_path.display()))?; + } + std::fs::write( + turn_context.config.codex_home.join("hooks.json"), + serde_json::json!({ + "hooks": { + "PermissionRequest": [{ + "hooks": [{ + "type": "command", + "command": script_path.display().to_string(), + }] + }] + } + }) + .to_string(), + ) + .context("write hooks.json")?; + + let mut hook_shell_argv = session + .user_shell() + .derive_exec_args("", /*use_login_shell*/ false); + let hook_shell_program = hook_shell_argv.remove(0); + let _ = hook_shell_argv.pop(); + session.services.hooks = Hooks::new(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + shell_program: Some(hook_shell_program), + shell_args: hook_shell_argv, + ..HooksConfig::default() + }); + + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + turn_context.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + turn_context.sandbox_policy = Constrained::allow_any(sandbox_policy.clone()); + turn_context.file_system_sandbox_policy = read_only_file_system_sandbox_policy(); + turn_context.network_sandbox_policy = NetworkSandboxPolicy::Restricted; + + let workdir = AbsolutePathBuf::try_from(std::env::current_dir()?)?; + let target = std::env::temp_dir().join("execve-hook-short-circuit.txt"); + let target_str = target.display().to_string(); + let command = vec!["touch".to_string(), target_str.clone()]; + let expected_hook_command = + codex_shell_command::parse_command::shlex_join(&["/usr/bin/touch".to_string(), target_str]); + let provider = CoreShellActionProvider { + policy: std::sync::Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: std::sync::Arc::new(session), + turn: std::sync::Arc::new(turn_context), + call_id: "execve-hook-call".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::OnRequest, + sandbox_policy, + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = tokio::time::timeout( + Duration::from_secs(5), + codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/touch") + .context("build touch absolute path")?, + &command, + &workdir, + ), + ) + .await + .context("timed out waiting for execve permission hook decision")??; + assert!(matches!( + action, + codex_shell_escalation::EscalationDecision::Escalate( + codex_shell_escalation::EscalationExecution::Unsandboxed + ) + )); + + let hook_inputs: Vec = std::fs::read_to_string(&log_path) + .with_context(|| format!("read hook log at {}", log_path.display()))? + .lines() + .map(serde_json::from_str) + .collect::>() + .context("parse hook log")?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + expected_hook_command + ); + assert_eq!( + hook_inputs[0]["tool_input"]["description"], + serde_json::Value::Null + ); + + Ok(()) +} + #[test] fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() { let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b241d4738..fdd434e54 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -21,6 +21,7 @@ use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; +use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; @@ -50,6 +51,7 @@ use std::collections::HashMap; #[derive(Clone, Debug)] pub struct UnifiedExecRequest { pub command: Vec, + pub hook_command: String, pub process_id: i32, pub cwd: AbsolutePathBuf, pub env: HashMap, @@ -179,6 +181,17 @@ impl Approvable for UnifiedExecRuntime<'_> { Some(req.exec_approval_requirement.clone()) } + fn permission_request_payload( + &self, + req: &UnifiedExecRequest, + ) -> Option { + Some(PermissionRequestPayload { + tool_name: "Bash".to_string(), + command: req.hook_command.clone(), + description: req.justification.clone(), + }) + } + fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } @@ -194,6 +207,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt Some(NetworkApprovalSpec { network: req.network.clone(), mode: NetworkApprovalMode::Deferred, + command: req.hook_command.clone(), }) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 48543bdcd..d85298734 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -131,6 +131,13 @@ pub(crate) struct ApprovalCtx<'a> { pub network_approval_context: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PermissionRequestPayload { + pub tool_name: String, + pub command: String, + pub description: Option, +} + // Specifies what tool orchestrator should do with a given tool call. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ExecApprovalRequirement { @@ -273,6 +280,12 @@ pub(crate) trait Approvable { None } + /// Return hook input for approval-time policy hooks when this runtime wants + /// hook evaluation to run before guardian or user approval. + fn permission_request_payload(&self, _req: &Req) -> Option { + None + } + /// Decide we can request an approval for no-sandbox execution. fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool { match policy { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 3f3b018df..4285ef2e5 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -88,6 +88,7 @@ impl UnifiedExecContext { #[derive(Debug)] pub(crate) struct ExecCommandRequest { pub command: Vec, + pub hook_command: String, pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 9f9189abc..518135969 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -751,6 +751,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), + hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, env, diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 47cd251bb..ee63ca77b 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3,17 +3,27 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::NetworkConstraints; +use codex_core::config_loader::NetworkRequirementsToml; +use codex_core::config_loader::RequirementSource; +use codex_core::config_loader::Sourced; use codex_features::Feature; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; 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; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_response_created; @@ -28,13 +38,18 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; +use std::sync::Arc; use std::time::Duration; +use tempfile::TempDir; use tokio::sync::oneshot; use tokio::time::sleep; +use tokio::time::timeout; const FIRST_CONTINUATION_PROMPT: &str = "Retry with exactly the phrase meow meow meow."; const SECOND_CONTINUATION_PROMPT: &str = "Now tighten it to just: meow."; const BLOCKED_PROMPT_CONTEXT: &str = "Remember the blocked lighthouse note."; +const PERMISSION_REQUEST_HOOK_MATCHER: &str = "^Bash$"; +const PERMISSION_REQUEST_ALLOW_REASON: &str = "should not be used for allow"; fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> { let script_path = home.join("stop_hook.py"); @@ -237,6 +252,88 @@ elif mode == "exit_2": Ok(()) } +fn write_permission_request_hook( + home: &Path, + matcher: Option<&str>, + mode: &str, + reason: &str, +) -> Result<()> { + let script_path = home.join("permission_request_hook.py"); + let log_path = home.join("permission_request_hook_log.jsonl"); + let mode_json = serde_json::to_string(mode).context("serialize permission request mode")?; + let reason_json = + serde_json::to_string(reason).context("serialize permission request reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +mode = {mode_json} +reason = {reason_json} + +payload = json.load(sys.stdin) + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if mode == "allow": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PermissionRequest", + "decision": {{"behavior": "allow"}} + }} + }})) +elif mode == "deny": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PermissionRequest", + "decision": {{ + "behavior": "deny", + "message": reason + }} + }} + }})) +elif mode == "exit_2": + sys.stderr.write(reason + "\n") + raise SystemExit(2) +"#, + log_path = log_path.display(), + mode_json = mode_json, + reason_json = reason_json, + ); + + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running permission request hook", + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = Value::String(matcher.to_string()); + } + + let hooks = serde_json::json!({ + "hooks": { + "PermissionRequest": [group] + } + }); + + fs::write(&script_path, script).context("write permission request hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn install_allow_permission_request_hook(home: &Path) -> Result<()> { + write_permission_request_hook( + home, + Some(PERMISSION_REQUEST_HOOK_MATCHER), + "allow", + PERMISSION_REQUEST_ALLOW_REASON, + ) +} + fn write_post_tool_use_hook( home: &Path, matcher: Option<&str>, @@ -397,6 +494,46 @@ fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> .collect() } +fn read_permission_request_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("permission_request_hook_log.jsonl")) + .context("read permission request hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse permission request hook log line")) + .collect() +} + +fn assert_permission_request_hook_input( + hook_input: &Value, + command: &str, + description: Option<&str>, +) { + assert_eq!(hook_input["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_input["tool_name"], "Bash"); + assert_eq!(hook_input["tool_input"]["command"], command); + assert_eq!( + hook_input["tool_input"]["description"], + description.map_or(Value::Null, Value::from) + ); + assert!(hook_input.get("approval_attempt").is_none()); + assert!(hook_input.get("sandbox_permissions").is_none()); + assert!(hook_input.get("additional_permissions").is_none()); + assert!(hook_input.get("justification").is_none()); + assert!(hook_input.get("host").is_none()); + assert!(hook_input.get("protocol").is_none()); +} + +fn assert_single_permission_request_hook_input( + home: &Path, + command: &str, + description: Option<&str>, +) -> Result> { + let hook_inputs = read_permission_request_hook_inputs(home)?; + assert_eq!(hook_inputs.len(), 1); + assert_permission_request_hook_input(&hook_inputs[0], command, description); + Ok(hook_inputs) +} + fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("post_tool_use_hook_log.jsonl")) .context("read post tool use hook log")? @@ -1005,6 +1142,392 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + 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!("rm -f {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "permission request hook allowed it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = install_allow_permission_request_hook(home) { + panic!("failed to write permission request hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + 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, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + requests[1].function_call_output(call_id); + assert!( + !marker.exists(), + "approved command should remove marker file" + ); + + let hook_inputs = assert_single_permission_request_hook_input( + test.codex_home_path(), + &command, + /*description*/ None, + )?; + assert!( + hook_inputs[0].get("tool_use_id").is_none(), + "PermissionRequest input should not include a tool_use_id", + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "permissionrequest-exec-command"; + let marker = std::env::temp_dir().join("permissionrequest-exec-command-marker"); + let command = format!("rm -f {}", marker.display()); + let justification = "remove the temporary marker"; + let args = serde_json::json!({ + "cmd": command, + "login": true, + "sandbox_permissions": "require_escalated", + "justification": justification, + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "permission request hook allowed exec_command"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = install_allow_permission_request_hook(home) { + panic!("failed to write permission request hook test fixture: {error}"); + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + fs::write(&marker, "seed").context("create exec command permission request marker")?; + + test.submit_turn_with_policies( + "run the exec command after hook approval", + AskForApproval::OnRequest, + codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + requests[1].function_call_output(call_id); + assert!( + !marker.exists(), + "approved exec command should remove marker file" + ); + + assert_single_permission_request_hook_input( + test.codex_home_path(), + &command, + Some(justification), + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_hook_allows_network_approval_without_prompt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "limited" +allow_local_binding = true +"#, + )?; + let call_id = "permissionrequest-network-approval"; + let command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=2).read().decode(errors='replace'))""#; + let args = serde_json::json!({ "command": command }); + let _responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "permission request hook allowed network access"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let approval_policy = AskForApproval::OnFailure; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let sandbox_policy_for_config = sandbox_policy.clone(); + let test = test_codex() + .with_home(Arc::clone(&home)) + .with_pre_build_hook(|home| { + if let Err(error) = install_allow_permission_request_hook(home) { + panic!("failed to write permission request hook test fixture: {error}"); + } + }) + .with_config(move |config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + let layers = config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .cloned() + .collect(); + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.network = Some(Sourced::new( + NetworkConstraints { + enabled: Some(true), + allow_local_binding: Some(true), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )); + let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); + requirements_toml.network = Some(NetworkRequirementsToml { + enabled: Some(true), + allow_local_binding: Some(true), + ..Default::default() + }); + config.config_layer_stack = + ConfigLayerStack::new(layers, requirements, requirements_toml) + .expect("rebuild config layer stack with network requirements"); + }) + .build(&server) + .await?; + assert!( + test.config.managed_network_requirements_enabled(), + "expected managed network requirements to be enabled" + ); + assert!( + test.config.permissions.network.is_some(), + "expected managed network proxy config to be present" + ); + test.session_configured + .network_proxy + .as_ref() + .expect("expected runtime managed network proxy addresses"); + + test.submit_turn_with_policies( + "run the shell command after network hook approval", + approval_policy, + sandbox_policy, + ) + .await?; + + timeout(Duration::from_secs(10), async { + loop { + if test + .codex_home_path() + .join("permission_request_hook_log.jsonl") + .exists() + { + break; + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("expected network approval hook to run"); + + assert!( + timeout( + Duration::from_secs(2), + wait_for_event(&test.codex, |event| matches!( + event, + EventMsg::ExecApprovalRequest(_) + )) + ) + .await + .is_err(), + "expected the network approval hook to bypass the approval prompt" + ); + + assert_single_permission_request_hook_input( + test.codex_home_path(), + command, + Some("network-access http://codex-network-test.invalid:80"), + )?; + + test.codex.submit(Op::Shutdown {}).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "permissionrequest-retry-shell-command"; + let marker = "permissionrequest_retry_marker.txt"; + let command = format!("printf retry > {marker}"); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "permission request hook allowed retry"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = install_allow_permission_request_hook(home) { + panic!("failed to write permission request hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + let marker_path = test.workspace_path(marker); + let _ = fs::remove_file(&marker_path); + + test.submit_turn_with_policies( + "retry the shell command after sandbox denial", + AskForApproval::OnFailure, + codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + requests[1].function_call_output(call_id); + assert_eq!( + fs::read_to_string(&marker_path).context("read retry marker")?, + "retry" + ); + + assert_single_permission_request_hook_input( + test.codex_home_path(), + &command, + /*description*/ None, + )?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json new file mode 100644 index 000000000..744ab0cf9 --- /dev/null +++ b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PermissionRequestToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PermissionRequest", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": { + "$ref": "#/definitions/PermissionRequestToolInput" + }, + "tool_name": { + "const": "Bash", + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "transcript_path", + "turn_id" + ], + "title": "permission-request.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json new file mode 100644 index 000000000..21d45382b --- /dev/null +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop" + ], + "type": "string" + }, + "PermissionRequestBehaviorWire": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "PermissionRequestDecisionWire": { + "additionalProperties": false, + "properties": { + "behavior": { + "$ref": "#/definitions/PermissionRequestBehaviorWire" + }, + "interrupt": { + "default": false, + "description": "Reserved for future short-circuiting semantics.\n\nPermissionRequest hooks currently fail closed if this field is `true`.", + "type": "boolean" + }, + "message": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null, + "description": "Reserved for a future input-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + }, + "updatedPermissions": { + "default": null, + "description": "Reserved for a future permission-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + } + }, + "required": [ + "behavior" + ], + "type": "object" + }, + "PermissionRequestHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PermissionRequestDecisionWire" + } + ], + "default": null + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PermissionRequestHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "permission-request.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index dc0425a75..43a2a4828 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -11,6 +11,7 @@ "HookEventNameWire": { "enum": [ "PreToolUse", + "PermissionRequest", "PostToolUse", "SessionStart", "UserPromptSubmit", diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index a7ff1d5f7..ba6bb3401 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -5,6 +5,7 @@ "HookEventNameWire": { "enum": [ "PreToolUse", + "PermissionRequest", "PostToolUse", "SessionStart", "UserPromptSubmit", diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index d79ab2a9a..30d2b0f97 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -5,6 +5,7 @@ "HookEventNameWire": { "enum": [ "PreToolUse", + "PermissionRequest", "PostToolUse", "SessionStart", "UserPromptSubmit", diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 4f63bec89..26353d97d 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -11,6 +11,7 @@ "HookEventNameWire": { "enum": [ "PreToolUse", + "PermissionRequest", "PostToolUse", "SessionStart", "UserPromptSubmit", diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 839fee825..f70a6fceb 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -10,6 +10,8 @@ pub(crate) struct HooksFile { pub(crate) struct HookEvents { #[serde(rename = "PreToolUse", default)] pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, #[serde(rename = "PostToolUse", default)] pub post_tool_use: Vec, #[serde(rename = "SessionStart", default)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 682328e64..3b6354e82 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -65,6 +65,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - let super::config::HookEvents { pre_tool_use, + permission_request, post_tool_use, session_start, user_prompt_submit, @@ -76,6 +77,10 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - codex_protocol::protocol::HookEventName::PreToolUse, pre_tool_use, ), + ( + codex_protocol::protocol::HookEventName::PermissionRequest, + permission_request, + ), ( codex_protocol::protocol::HookEventName::PostToolUse, post_tool_use, diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index fbb2a1843..bd3a04927 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -32,6 +32,7 @@ pub(crate) fn select_handlers( .filter(|handler| handler.event_name == event_name) .filter(|handler| match event_name { HookEventName::PreToolUse + | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart => { matches_matcher(handler.matcher.as_deref(), matcher_input) @@ -111,6 +112,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart => HookScope::Thread, HookEventName::PreToolUse + | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 9bc23705d..b4d59fc6c 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; +use crate::events::permission_request::PermissionRequestOutcome; +use crate::events::permission_request::PermissionRequestRequest; use crate::events::post_tool_use::PostToolUseOutcome; use crate::events::post_tool_use::PostToolUseRequest; use crate::events::pre_tool_use::PreToolUseOutcome; @@ -52,6 +54,7 @@ impl ConfiguredHandler { fn event_name_label(&self) -> &'static str { match self.event_name { codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", + codex_protocol::protocol::HookEventName::PermissionRequest => "permission-request", codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", @@ -105,6 +108,13 @@ impl ClaudeHooksEngine { crate::events::pre_tool_use::preview(&self.handlers, request) } + pub(crate) fn preview_permission_request( + &self, + request: &PermissionRequestRequest, + ) -> Vec { + crate::events::permission_request::preview(&self.handlers, request) + } + pub(crate) fn preview_post_tool_use( &self, request: &PostToolUseRequest, @@ -124,6 +134,13 @@ impl ClaudeHooksEngine { crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await } + pub(crate) async fn run_permission_request( + &self, + request: PermissionRequestRequest, + ) -> PermissionRequestOutcome { + crate::events::permission_request::run(&self.handlers, &self.shell, request).await + } + pub(crate) async fn run_post_tool_use( &self, request: PostToolUseRequest, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index dd9ab6416..0a3a994e1 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -19,6 +19,19 @@ pub(crate) struct PreToolUseOutput { pub invalid_reason: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PermissionRequestDecision { + Allow, + Deny { message: String }, +} + +#[derive(Debug, Clone)] +pub(crate) struct PermissionRequestOutput { + pub universal: UniversalOutput, + pub decision: Option, + pub invalid_reason: Option, +} + #[derive(Debug, Clone)] pub(crate) struct PostToolUseOutput { pub universal: UniversalOutput, @@ -48,6 +61,9 @@ pub(crate) struct StopOutput { use crate::schema::BlockDecisionWire; use crate::schema::HookUniversalOutputWire; +use crate::schema::PermissionRequestBehaviorWire; +use crate::schema::PermissionRequestCommandOutputWire; +use crate::schema::PermissionRequestDecisionWire; use crate::schema::PostToolUseCommandOutputWire; use crate::schema::PreToolUseCommandOutputWire; use crate::schema::PreToolUseDecisionWire; @@ -115,6 +131,29 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { }) } +pub(crate) fn parse_permission_request(stdout: &str) -> Option { + let wire: PermissionRequestCommandOutputWire = parse_json(stdout)?; + let universal = UniversalOutput::from(wire.universal); + let hook_specific_output = wire.hook_specific_output.as_ref(); + let decision = hook_specific_output.and_then(|output| output.decision.as_ref()); + let invalid_reason = unsupported_permission_request_universal(&universal).or_else(|| { + hook_specific_output.and_then(|output| { + unsupported_permission_request_hook_specific_output(output.decision.as_ref()) + }) + }); + let decision = if invalid_reason.is_none() { + decision.map(permission_request_decision) + } else { + None + }; + + Some(PermissionRequestOutput { + universal, + decision, + invalid_reason, + }) +} + pub(crate) fn parse_post_tool_use(stdout: &str) -> Option { let wire: PostToolUseCommandOutputWire = parse_json(stdout)?; let universal = UniversalOutput::from(wire.universal); @@ -235,6 +274,18 @@ fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option Option { + if !universal.continue_processing { + Some("PermissionRequest hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("PermissionRequest hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("PermissionRequest hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option { if universal.suppress_output { Some("PostToolUse hook returned unsupported suppressOutput".to_string()) @@ -243,6 +294,36 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option, +) -> Option { + let decision = decision?; + if decision.updated_input.is_some() { + Some("PermissionRequest hook returned unsupported updatedInput".to_string()) + } else if decision.updated_permissions.is_some() { + Some("PermissionRequest hook returned unsupported updatedPermissions".to_string()) + } else if decision.interrupt { + Some("PermissionRequest hook returned unsupported interrupt:true".to_string()) + } else { + None + } +} + +fn permission_request_decision( + decision: &PermissionRequestDecisionWire, +) -> PermissionRequestDecision { + match decision.behavior { + PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow, + PermissionRequestBehaviorWire::Deny => PermissionRequestDecision::Deny { + message: decision + .message + .as_deref() + .and_then(trimmed_reason) + .unwrap_or_else(|| "PermissionRequest hook denied approval".to_string()), + }, + } +} + fn unsupported_post_tool_use_hook_specific_output( output: &crate::schema::PostToolUseHookSpecificOutputWire, ) -> Option { @@ -334,3 +415,80 @@ fn trimmed_reason(reason: &str) -> Option { Some(trimmed.to_string()) } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::parse_permission_request; + + #[test] + fn permission_request_rejects_reserved_updated_input_field() { + let parsed = parse_permission_request( + &json!({ + "continue": true, + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "allow", + "updatedInput": {} + } + } + }) + .to_string(), + ) + .expect("permission request hook output should parse"); + + assert_eq!( + parsed.invalid_reason, + Some("PermissionRequest hook returned unsupported updatedInput".to_string()) + ); + } + + #[test] + fn permission_request_rejects_reserved_updated_permissions_field() { + let parsed = parse_permission_request( + &json!({ + "continue": true, + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "allow", + "updatedPermissions": {} + } + } + }) + .to_string(), + ) + .expect("permission request hook output should parse"); + + assert_eq!( + parsed.invalid_reason, + Some("PermissionRequest hook returned unsupported updatedPermissions".to_string()) + ); + } + + #[test] + fn permission_request_rejects_reserved_interrupt_field() { + let parsed = parse_permission_request( + &json!({ + "continue": true, + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "allow", + "interrupt": true + } + } + }) + .to_string(), + ) + .expect("permission request hook output should parse"); + + assert_eq!( + parsed.invalid_reason, + Some("PermissionRequest hook returned unsupported interrupt:true".to_string()) + ); + } +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 644d1dd46..d8bdb141c 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -6,6 +6,8 @@ use serde_json::Value; pub(crate) struct GeneratedHookSchemas { pub post_tool_use_command_input: Value, pub post_tool_use_command_output: Value, + pub permission_request_command_input: Value, + pub permission_request_command_output: Value, pub pre_tool_use_command_input: Value, pub pre_tool_use_command_output: Value, pub session_start_command_input: Value, @@ -27,6 +29,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "post-tool-use.command.output", include_str!("../../schema/generated/post-tool-use.command.output.schema.json"), ), + permission_request_command_input: parse_json_schema( + "permission-request.command.input", + include_str!("../../schema/generated/permission-request.command.input.schema.json"), + ), + permission_request_command_output: parse_json_schema( + "permission-request.command.output", + include_str!("../../schema/generated/permission-request.command.output.schema.json"), + ), pre_tool_use_command_input: parse_json_schema( "pre-tool-use.command.input", include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"), @@ -78,6 +88,8 @@ mod tests { assert_eq!(schemas.post_tool_use_command_input["type"], "object"); assert_eq!(schemas.post_tool_use_command_output["type"], "object"); + assert_eq!(schemas.permission_request_command_input["type"], "object"); + assert_eq!(schemas.permission_request_command_output["type"], "object"); assert_eq!(schemas.pre_tool_use_command_input["type"], "object"); assert_eq!(schemas.pre_tool_use_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 305a1214a..2b721aec8 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -100,9 +100,10 @@ pub(crate) fn matcher_pattern_for_event( matcher: Option<&str>, ) -> Option<&str> { match event_name { - HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => { - matcher - } + HookEventName::PreToolUse + | HookEventName::PermissionRequest + | HookEventName::PostToolUse + | HookEventName::SessionStart => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, } } diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 0d2e6d31c..52148324e 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod common; +pub mod permission_request; pub mod post_tool_use; pub mod pre_tool_use; pub mod session_start; diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs new file mode 100644 index 000000000..c6b58b86f --- /dev/null +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -0,0 +1,331 @@ +//! Permission-request hook execution. +//! +//! This event runs in the approval path, before guardian or user approval UI is +//! shown. Unlike `pre_tool_use`, handlers do not rewrite tool input or block by +//! stopping execution outright; instead they can return a concrete allow/deny +//! decision, or decline to decide and let the normal approval flow continue. +//! +//! The event also mirrors the rest of the hook system's lifecycle: +//! +//! 1. Preview matching handlers so the UI can render pending hook rows. +//! 2. Execute every matching handler in precedence order. +//! 3. Parse each handler into transcript-visible output plus an optional +//! decision. +//! 4. Fold the decisions conservatively: any deny wins, otherwise the last +//! allow wins, otherwise there is no hook verdict. +use std::path::PathBuf; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::engine::output_parser; +use crate::schema::PermissionRequestCommandInput; +use crate::schema::PermissionRequestToolInput; +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; + +#[derive(Debug, Clone)] +pub struct PermissionRequestRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub run_id_suffix: String, + pub command: String, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionRequestDecision { + Allow, + Deny { message: String }, +} + +#[derive(Debug)] +pub struct PermissionRequestOutcome { + pub hook_events: Vec, + pub decision: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PermissionRequestHandlerData { + decision: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &PermissionRequestRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PermissionRequest, + Some(&request.tool_name), + ) + .into_iter() + .map(|handler| { + common::hook_run_for_tool_use( + dispatcher::running_summary(&handler), + &request.run_id_suffix, + ) + }) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PermissionRequestRequest, +) -> PermissionRequestOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PermissionRequest, + Some(&request.tool_name), + ); + if matched.is_empty() { + return PermissionRequestOutcome { + hook_events: Vec::new(), + decision: None, + }; + } + + let input_json = match serde_json::to_string(&build_command_input(&request)) { + Ok(input_json) => input_json, + Err(error) => { + let hook_events = common::serialization_failure_hook_events_for_tool_use( + matched, + Some(request.turn_id.clone()), + format!("failed to serialize permission request hook input: {error}"), + &request.run_id_suffix, + ); + return PermissionRequestOutcome { + hook_events, + decision: None, + }; + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id.clone()), + parse_completed, + ) + .await; + + // Preserve the most specific matching allow, but treat any deny as final so + // broader policy layers cannot accidentally overrule a more specific block. + let decision = resolve_permission_request_decision( + results + .iter() + .filter_map(|result| result.data.decision.as_ref()), + ); + + PermissionRequestOutcome { + hook_events: results + .into_iter() + .map(|result| { + common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix) + }) + .collect(), + decision, + } +} + +/// Resolve matching hook decisions conservatively: any deny wins immediately; +/// otherwise keep the highest-precedence allow so more specific handlers +/// override broader ones. +fn resolve_permission_request_decision<'a>( + decisions: impl IntoIterator, +) -> Option { + let mut resolved_allow = None; + for decision in decisions { + match decision { + PermissionRequestDecision::Allow => { + resolved_allow = Some(PermissionRequestDecision::Allow); + } + PermissionRequestDecision::Deny { message } => { + return Some(PermissionRequestDecision::Deny { + message: message.clone(), + }); + } + } + } + resolved_allow +} + +fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput { + PermissionRequestCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "PermissionRequest".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + tool_name: request.tool_name.clone(), + tool_input: PermissionRequestToolInput { + command: request.command.clone(), + description: request.description.clone(), + }, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut decision = None; + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = + output_parser::parse_permission_request(&run_result.stdout) + { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(parsed_decision) = parsed.decision { + match parsed_decision { + output_parser::PermissionRequestDecision::Allow => { + decision = Some(PermissionRequestDecision::Allow); + } + output_parser::PermissionRequestDecision::Deny { message } => { + status = HookRunStatus::Blocked; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: message.clone(), + }); + decision = Some(PermissionRequestDecision::Deny { message }); + } + } + } + } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid permission-request JSON output".to_string(), + }); + } + } + Some(2) => { + if let Some(message) = common::trimmed_non_empty(&run_result.stderr) { + status = HookRunStatus::Blocked; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: message.clone(), + }); + decision = Some(PermissionRequestDecision::Deny { message }); + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PermissionRequest hook exited with code 2 but did not write a denial reason to stderr".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: PermissionRequestHandlerData { decision }, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::PermissionRequestDecision; + use super::resolve_permission_request_decision; + + #[test] + fn permission_request_deny_overrides_earlier_allow() { + let decisions = [ + PermissionRequestDecision::Allow, + PermissionRequestDecision::Deny { + message: "repo deny".to_string(), + }, + ]; + + assert_eq!( + resolve_permission_request_decision(decisions.iter()), + Some(PermissionRequestDecision::Deny { + message: "repo deny".to_string(), + }) + ); + } + + #[test] + fn permission_request_returns_allow_when_no_handler_denies() { + let decisions = [ + PermissionRequestDecision::Allow, + PermissionRequestDecision::Allow, + ]; + + assert_eq!( + resolve_permission_request_decision(decisions.iter()), + Some(PermissionRequestDecision::Allow) + ); + } + + #[test] + fn permission_request_returns_none_when_no_handler_decides() { + let decisions = Vec::::new(); + + assert_eq!(resolve_permission_request_decision(decisions.iter()), None); + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 784b41442..c8358c678 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,9 @@ mod registry; mod schema; mod types; +pub use events::permission_request::PermissionRequestDecision; +pub use events::permission_request::PermissionRequestOutcome; +pub use events::permission_request::PermissionRequestRequest; pub use events::post_tool_use::PostToolUseOutcome; pub use events::post_tool_use::PostToolUseRequest; pub use events::pre_tool_use::PreToolUseOutcome; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 9a9667f8a..6f4e56b1b 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,8 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::permission_request::PermissionRequestOutcome; +use crate::events::permission_request::PermissionRequestRequest; use crate::events::post_tool_use::PostToolUseOutcome; use crate::events::post_tool_use::PostToolUseRequest; use crate::events::pre_tool_use::PreToolUseOutcome; @@ -103,6 +105,13 @@ impl Hooks { self.engine.preview_pre_tool_use(request) } + pub fn preview_permission_request( + &self, + request: &PermissionRequestRequest, + ) -> Vec { + self.engine.preview_permission_request(request) + } + pub fn preview_post_tool_use( &self, request: &PostToolUseRequest, @@ -122,6 +131,13 @@ impl Hooks { self.engine.run_pre_tool_use(request).await } + pub async fn run_permission_request( + &self, + request: PermissionRequestRequest, + ) -> PermissionRequestOutcome { + self.engine.run_permission_request(request).await + } + pub async fn run_post_tool_use(&self, request: PostToolUseRequest) -> PostToolUseOutcome { self.engine.run_post_tool_use(request).await } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index b1a5017cd..d7693ac70 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -15,6 +15,8 @@ use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json"; const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json"; +const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json"; +const PERMISSION_REQUEST_OUTPUT_FIXTURE: &str = "permission-request.command.output.schema.json"; const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json"; const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; @@ -69,6 +71,8 @@ pub(crate) struct HookUniversalOutputWire { pub(crate) enum HookEventNameWire { #[serde(rename = "PreToolUse")] PreToolUse, + #[serde(rename = "PermissionRequest")] + PermissionRequest, #[serde(rename = "PostToolUse")] PostToolUse, #[serde(rename = "SessionStart")] @@ -109,6 +113,58 @@ pub(crate) struct PostToolUseCommandOutputWire { pub hook_specific_output: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "permission-request.command.output")] +pub(crate) struct PermissionRequestCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PermissionRequestHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub decision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PermissionRequestDecisionWire { + pub behavior: PermissionRequestBehaviorWire, + /// Reserved for a future input-rewrite capability. + /// + /// PermissionRequest hooks currently fail closed if this field is present. + #[serde(default)] + pub updated_input: Option, + /// Reserved for a future permission-rewrite capability. + /// + /// PermissionRequest hooks currently fail closed if this field is present. + #[serde(default)] + pub updated_permissions: Option, + #[serde(default)] + pub message: Option, + /// Reserved for future short-circuiting semantics. + /// + /// PermissionRequest hooks currently fail closed if this field is `true`. + #[serde(default)] + pub interrupt: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PermissionRequestBehaviorWire { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -181,6 +237,34 @@ pub(crate) struct PreToolUseCommandInput { pub tool_use_id: String, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PermissionRequestToolInput { + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "permission-request.command.input")] +pub(crate) struct PermissionRequestCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "permission_request_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "permission_request_tool_name_schema")] + pub tool_name: String, + pub tool_input: PermissionRequestToolInput, +} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -358,6 +442,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), schema_json::()?, @@ -461,10 +553,18 @@ fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("PreToolUse") } +fn permission_request_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("PermissionRequest") +} + fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("Bash") } +fn permission_request_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("Bash") +} + fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("UserPromptSubmit") } @@ -516,10 +616,13 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::PERMISSION_REQUEST_INPUT_FIXTURE; + use super::PERMISSION_REQUEST_OUTPUT_FIXTURE; use super::POST_TOOL_USE_INPUT_FIXTURE; use super::POST_TOOL_USE_OUTPUT_FIXTURE; use super::PRE_TOOL_USE_INPUT_FIXTURE; use super::PRE_TOOL_USE_OUTPUT_FIXTURE; + use super::PermissionRequestCommandInput; use super::PostToolUseCommandInput; use super::PreToolUseCommandInput; use super::SESSION_START_INPUT_FIXTURE; @@ -544,6 +647,12 @@ mod tests { POST_TOOL_USE_OUTPUT_FIXTURE => { include_str!("../schema/generated/post-tool-use.command.output.schema.json") } + PERMISSION_REQUEST_INPUT_FIXTURE => { + include_str!("../schema/generated/permission-request.command.input.schema.json") + } + PERMISSION_REQUEST_OUTPUT_FIXTURE => { + include_str!("../schema/generated/permission-request.command.output.schema.json") + } PRE_TOOL_USE_INPUT_FIXTURE => { include_str!("../schema/generated/pre-tool-use.command.input.schema.json") } @@ -585,6 +694,8 @@ mod tests { for fixture in [ POST_TOOL_USE_INPUT_FIXTURE, POST_TOOL_USE_OUTPUT_FIXTURE, + PERMISSION_REQUEST_INPUT_FIXTURE, + PERMISSION_REQUEST_OUTPUT_FIXTURE, PRE_TOOL_USE_INPUT_FIXTURE, PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, @@ -615,6 +726,11 @@ mod tests { .expect("serialize post tool use input schema"), ) .expect("parse post tool use input schema"); + let permission_request: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize permission request input schema"), + ) + .expect("parse permission request input schema"); let user_prompt_submit: Value = serde_json::from_slice( &schema_json::() .expect("serialize user prompt submit input schema"), @@ -625,7 +741,13 @@ mod tests { ) .expect("parse stop input schema"); - for schema in [&pre_tool_use, &post_tool_use, &user_prompt_submit, &stop] { + for schema in [ + &pre_tool_use, + &permission_request, + &post_tool_use, + &user_prompt_submit, + &stop, + ] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); assert!( schema["required"] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b777b4d32..91f368e39 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1620,6 +1620,7 @@ pub enum EventMsg { #[serde(rename_all = "snake_case")] pub enum HookEventName { PreToolUse, + PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9e365afa7..a7d8a355c 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1068,6 +1068,7 @@ pub(super) async fn assert_hook_events_snapshot( fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", + codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 50642aa0f..138a17b5a 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -701,6 +701,7 @@ fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str { fn hook_event_label(event_name: HookEventName) -> &'static str { match event_name { HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit",