diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4f5410c782..f97a6df11f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1481,6 +1481,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 e4ced5070c..665d030a70 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 @@ -8494,6 +8494,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 d1f3cbda55..6cf8364813 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 @@ -5233,6 +5233,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 bce797086c..17e8d2f290 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -4,6 +4,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 72f32d0d9d..f0c412736e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -4,6 +4,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 b97c709b98..28657d2282 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 66ac1a6020..285a1247f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -379,7 +379,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 0d620119f7..6ce74467e5 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,9 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PermissionRequestDecision; +use codex_hooks::PermissionRequestOutcome; +use codex_hooks::PermissionRequestRequest; use codex_hooks::PostToolUseOutcome; use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; @@ -145,6 +148,36 @@ pub(crate) async fn run_pre_tool_use_hooks( if should_block { block_reason } else { None } } +pub(crate) async fn run_permission_request_hooks( + sess: &Arc, + turn_context: &Arc, + run_id_suffix: String, + tool_name: String, + command: String, +) -> 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, + run_id_suffix, + command, + }; + 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, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 970afd6f33..60f9587c45 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.clone(), 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.clone(), exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), additional_permissions: None, prefix_rule: None, session, @@ -364,6 +367,7 @@ impl ToolHandler for ShellCommandHandler { ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, + hook_command: params.command, additional_permissions: params.additional_permissions.clone(), prefix_rule, session, @@ -382,6 +386,7 @@ impl ShellHandler { let RunExecLikeArgs { tool_name, exec_params, + hook_command, additional_permissions, prefix_rule, session, @@ -513,6 +518,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/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index c05009ff16..2473919b3f 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -8,6 +8,7 @@ caching). */ use crate::guardian::guardian_rejection_message; 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; @@ -22,6 +23,8 @@ 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::SessionTelemetry; use codex_otel::ToolDecisionSource; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -112,10 +115,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; - // 1) Approval let mut already_approved = false; @@ -124,7 +123,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)); @@ -137,14 +141,16 @@ impl ToolOrchestrator { retry_reason: reason, network_approval_context: None, }; - let decision = tool.start_approval_async(req, approval_ctx).await; - let otel_source = if routes_approval_to_guardian(turn_ctx) { - 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, + approval_ctx, + turn_ctx, + &otel, + otel_tn, + otel_ci, + ) + .await?; match decision { ReviewDecision::Denied | ReviewDecision::Abort => { @@ -292,13 +298,16 @@ impl ToolOrchestrator { network_approval_context: network_approval_context.clone(), }; - let decision = tool.start_approval_async(req, approval_ctx).await; - let otel_source = if routes_approval_to_guardian(turn_ctx) { - otel_automated_reviewer - } else { - otel_user - }; - otel.tool_decision(otel_tn, otel_ci, &decision, otel_source); + let decision = Self::request_approval( + tool, + req, + approval_ctx, + turn_ctx, + &otel, + otel_tn, + otel_ci, + ) + .await?; match decision { ReviewDecision::Denied | ReviewDecision::Abort => { @@ -361,6 +370,64 @@ impl ToolOrchestrator { Err(err) => Err(err), } } + + // PermissionRequest hooks get the first chance to answer approval prompts + // for tools that expose a hook payload. Today this is Bash-only; if no + // matching hook returns a decision, fall back to the normal user or guardian + // approval path. + async fn request_approval( + tool: &mut T, + req: &Rq, + approval_ctx: ApprovalCtx<'_>, + turn_ctx: &crate::codex::TurnContext, + otel: &SessionTelemetry, + otel_tn: &str, + otel_ci: &str, + ) -> 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, + approval_ctx.call_id.to_string(), + permission_request.tool_name, + permission_request.command, + ) + .await + { + Some(PermissionRequestDecision::Allow) => { + otel.tool_decision( + otel_tn, + otel_ci, + &ReviewDecision::Approved, + ToolDecisionSource::Config, + ); + return Ok(ReviewDecision::Approved); + } + Some(PermissionRequestDecision::Deny { message }) => { + otel.tool_decision( + otel_tn, + otel_ci, + &ReviewDecision::Denied, + ToolDecisionSource::Config, + ); + return Err(ToolError::Rejected(message)); + } + None => {} + } + } + + let decision = tool.start_approval_async(req, approval_ctx).await; + let otel_source = if routes_approval_to_guardian(turn_ctx) { + ToolDecisionSource::AutomatedReviewer + } else { + ToolDecisionSource::User + }; + otel.tool_decision(otel_tn, otel_ci, &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 4ff04b002b..98a5b6d0c4 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -24,6 +24,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; @@ -45,6 +46,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, @@ -196,6 +198,10 @@ impl Approvable for ShellRuntime { Some(req.exec_approval_requirement.clone()) } + fn permission_request_payload(&self, req: &ShellRequest) -> Option { + Some(PermissionRequestPayload::bash(req.hook_command.clone())) + } + fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index fd12607c1b..519728f576 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, @@ -176,6 +178,13 @@ impl Approvable for UnifiedExecRuntime<'_> { Some(req.exec_approval_requirement.clone()) } + fn permission_request_payload( + &self, + req: &UnifiedExecRequest, + ) -> Option { + Some(PermissionRequestPayload::bash(req.hook_command.clone())) + } + fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index b1ce3e7d92..b3c37af4fc 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -124,6 +124,21 @@ 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, +} + +impl PermissionRequestPayload { + pub(crate) fn bash(command: String) -> Self { + Self { + tool_name: "Bash".to_string(), + command, + } + } +} + // Specifies what tool orchestrator should do with a given tool call. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ExecApprovalRequirement { @@ -266,6 +281,10 @@ pub(crate) trait Approvable { None } + 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/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 9d77ac4e97..6710abe0ab 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -677,6 +677,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), + hook_command: codex_shell_command::parse_command::shlex_join(&request.command), 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 47cd251bbf..1825a44699 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -7,6 +7,7 @@ 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; @@ -237,6 +238,79 @@ 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 write_post_tool_use_hook( home: &Path, matcher: Option<&str>, @@ -397,6 +471,15 @@ 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 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 +1088,100 @@ 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!("printf allowed > {}", 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) = write_permission_request_hook( + home, + Some("^Bash$"), + "allow", + "should not be used for allow", + ) { + 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?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover 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); + 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"); + assert!( + output.contains("allowed"), + "shell command output should reach the model after hook approval", + ); + assert!( + marker.exists(), + "approved command should create 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]["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + 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 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 0000000000..9bdcacf0cb --- /dev/null +++ b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PermissionRequestToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "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 0000000000..d603dd0f09 --- /dev/null +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -0,0 +1,98 @@ +{ + "$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, + "type": "boolean" + }, + "message": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + }, + "updatedPermissions": { + "default": null + } + }, + "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 dc0425a755..43a2a4828e 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 a7ff1d5f7a..ba6bb3401b 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 d79ab2a9a0..30d2b0f977 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 4f63bec89e..26353d97db 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 839fee825c..f70a6fceb0 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 465caa376d..f14b6e6e07 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -64,6 +64,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, @@ -75,6 +76,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 642231ff19..5644ceb88d 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) @@ -109,6 +110,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 b6ae4a365e..28c5323bcf 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +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; @@ -51,6 +53,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", @@ -104,6 +107,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, @@ -123,6 +133,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 dd9ab64160..a95f57309b 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 { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 644d1dd46d..d8bdb141c2 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 305a1214ad..2b721aec8f 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 0d2e6d31c4..52148324e2 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 0000000000..62a3265f7c --- /dev/null +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -0,0 +1,240 @@ +use std::path::PathBuf; + +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; + +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; + +#[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, +} + +#[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(&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: "Bash".to_string(), + tool_input: PermissionRequestToolInput { + command: request.command.clone(), + }, + }) { + 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; + + let decision = results + .iter() + .find_map(|result| result.data.decision.clone()); + + PermissionRequestOutcome { + hook_events: results + .into_iter() + .map(|result| { + common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix) + }) + .collect(), + decision, + } +} + +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 }, + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 784b414427..c8358c678c 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 9a9667f8a9..6f4e56b1bf 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 b1a5017cd1..51a635f761 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,49 @@ 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, + #[serde(default)] + pub updated_input: Option, + #[serde(default)] + pub updated_permissions: Option, + #[serde(default)] + pub message: Option, + #[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 +228,32 @@ 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, +} + +#[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 +431,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 +542,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 +605,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 +636,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 +683,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 +715,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 +730,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 44693033d8..a5fdb9485a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1591,6 +1591,7 @@ pub struct AddCreditsNudgeEmailResponseEvent { #[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 017685aa93..bf186ada85 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 bedf80cc45..f9c5d779b5 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",