Compare commits

...

23 Commits

Author SHA1 Message Date
Abhinav Vedmala
6fc19ca863 Make find_project_root private again 2026-04-14 11:52:13 -07:00
Abhinav Vedmala
88c42ae7cc Inline project codex home resolution 2026-04-14 11:51:32 -07:00
Abhinav Vedmala
f60b2cc5b1 Extract shared project root resolution helper 2026-04-14 11:47:48 -07:00
Abhinav
a97234a252 Merge branch 'codex/permission-request-hook-suggestions' into codex/permission-request-updated-permissions 2026-04-13 21:39:38 -07:00
Abhinav Vedmala
db45ef9186 Gate hook permission suggestions on available approval decisions
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 21:01:37 -07:00
Abhinav Vedmala
c6687ba493 Support PermissionRequest updatedPermissions
Handle allow-hook updatedPermissions for exec-rule AddRules suggestions and apply the selected updates to session, project, or user rule state.

Co-authored-by: Codex <noreply@openai.com>
2026-04-13 20:29:55 -07:00
Abhinav Vedmala
91887885ee Add permission suggestions to PermissionRequest hooks
Expose top-level permission suggestions in PermissionRequest hook inputs and build them from source approval context so deferred unified-exec network retries carry the same suggestion data as immediate approval paths.

Co-authored-by: Codex <noreply@openai.com>
2026-04-13 19:39:08 -07:00
Abhinav Vedmala
7e4869308c fix lint 2026-04-13 17:17:15 -07:00
Abhinav Vedmala
144fcbe295 Drop PermissionRequestApprovalAttempt
Remove the approval-attempt enum and let callers provide the final permission-request hook run id suffix directly. This keeps retry hook runs unique without carrying an extra cross-crate type.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-04-13 17:04:04 -07:00
Abhinav Vedmala
32e26c49bc Simplify permission request hook plumbing
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 16:52:52 -07:00
Abhinav Vedmala
75cc778393 Use Bash for network approval hook payloads
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 16:37:18 -07:00
Abhinav Vedmala
04294e0038 Trim PermissionRequest hook inputs
Keep PermissionRequest hook payloads focused on tool identity and the actionable command details. For Bash and exec_command hooks, plumb request justification into tool_input.description when present. For NetworkAccess hooks, pass the originating command and a network-access <domain> description instead of the old approval context envelope.

Co-authored-by: Codex <noreply@openai.com>
2026-04-13 16:24:45 -07:00
Abhinav Vedmala
2563661366 Restore permission request attempt context
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 14:18:16 -07:00
Abhinav Vedmala
20e0ffabef Simplify permission request hook context
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 13:53:44 -07:00
Abhinav Vedmala
8e7a23c48c Reshape permission request hook payload
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 13:45:41 -07:00
Abhinav Vedmala
37e9f255ed Run permission hooks for network approvals
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 13:17:26 -07:00
Abhinav Vedmala
920307ea40 Add permission request hook coverage for exec approvals
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 17:30:35 -07:00
Abhinav Vedmala
1bf5222fbb Clean up permission request approval flow
Replace stringly approval-attempt plumbing with a shared enum, centralize approval decision handling in the orchestrator, and document plus test the reserved PermissionRequest output fields.

Co-authored-by: Codex <noreply@openai.com>
2026-04-12 14:00:54 -07:00
Abhinav Vedmala
86282db6c1 Clarify permission request decision comment
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 13:46:36 -07:00
Abhinav Vedmala
5096cc3adb Document permission request hook flow
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 13:42:31 -07:00
Abhinav Vedmala
f6517fa6a2 Fix permission request hook precedence and retry context 2026-04-12 13:17:13 -07:00
Abhinav Vedmala
99458d2929 Include approval context in PermissionRequest hooks
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 12:40:24 -07:00
Abhinav Vedmala
528bdb488a Add Bash PermissionRequest hooks
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 12:27:11 -07:00
46 changed files with 2974 additions and 131 deletions

View File

@@ -1404,6 +1404,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -8395,6 +8395,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -5147,6 +5147,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -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";

View File

@@ -380,7 +380,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
}
);

View File

@@ -25,6 +25,7 @@ use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
use toml::Value as TomlValue;
use tracing::warn;
pub use codex_config::AppRequirementToml;
pub use codex_config::AppsRequirementsToml;
@@ -760,6 +761,31 @@ async fn find_project_root(
Ok(cwd.clone())
}
pub(crate) async fn resolve_project_root(
config_layer_stack: &ConfigLayerStack,
cwd: &AbsolutePathBuf,
) -> io::Result<AbsolutePathBuf> {
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
continue;
}
merge_toml_values(&mut merged, &layer.config);
}
let project_root_markers = match project_root_markers_from_config(&merged) {
Ok(Some(markers)) => markers,
Ok(None) => default_project_root_markers(),
Err(err) => {
warn!("invalid project_root_markers: {err}");
default_project_root_markers()
}
};
find_project_root(cwd, &project_root_markers).await
}
/// Return the appropriate list of layers (each with
/// [ConfigLayerSource::Project] as the source) between `cwd` and
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,

View File

@@ -328,6 +328,21 @@ impl ExecPolicyManager {
source,
})?;
self.update_current_policy_if_needed(amendment)
}
pub(crate) async fn add_amendment_to_current_policy(
&self,
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
let _update_guard = self.update_lock.lock().await;
self.update_current_policy_if_needed(amendment)
}
fn update_current_policy_if_needed(
&self,
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
let current_policy = self.current();
let match_options = MatchOptions {
resolve_host_executables: true,

View File

@@ -1,6 +1,13 @@
use std::future::Future;
use std::sync::Arc;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PermissionRequestOutcome;
use codex_hooks::PermissionRequestRequest;
use codex_hooks::PermissionSuggestion;
use codex_hooks::PermissionSuggestionDestination;
use codex_hooks::PermissionSuggestionRule;
use codex_hooks::PermissionSuggestionType;
use codex_hooks::PostToolUseOutcome;
use codex_hooks::PostToolUseRequest;
use codex_hooks::PreToolUseOutcome;
@@ -8,21 +15,27 @@ use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::items::TurnItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
use tracing::warn;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config_loader::resolve_project_root;
use crate::event_mapping::parse_turn_item;
use crate::tools::sandboxing::PermissionRequestPayload;
pub(crate) struct HookRuntimeOutcome {
pub should_stop: bool,
@@ -145,6 +158,132 @@ 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<Session>,
turn_context: &Arc<TurnContext>,
run_id_suffix: &str,
payload: PermissionRequestPayload,
) -> Option<PermissionRequestDecision> {
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,
permission_suggestions: payload.permission_suggestions,
};
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;
if let Some(PermissionRequestDecision::Allow {
updated_permissions,
}) = &decision
{
apply_permission_updates_from_hook(sess, turn_context, updated_permissions).await;
}
decision
}
async fn apply_permission_updates_from_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
updated_permissions: &[PermissionSuggestion],
) {
for permission in updated_permissions {
if let Err(err) = apply_permission_update_from_hook(sess, turn_context, permission).await {
let message =
format!("PermissionRequest hook failed to apply updated permission: {err}");
warn!("{message}");
sess.send_event_raw(Event {
id: turn_context.sub_id.clone(),
msg: EventMsg::Warning(WarningEvent { message }),
})
.await;
}
}
}
async fn apply_permission_update_from_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
permission: &PermissionSuggestion,
) -> Result<(), String> {
match permission.suggestion_type {
PermissionSuggestionType::AddRules => {
for rule in &permission.rules {
match rule {
PermissionSuggestionRule::PrefixRule { command } => {
let amendment = ExecPolicyAmendment::new(command.clone());
apply_execpolicy_amendment_destination(
sess,
turn_context,
&permission.destination,
&amendment,
)
.await?;
}
}
}
Ok(())
}
}
}
async fn apply_execpolicy_amendment_destination(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
destination: &PermissionSuggestionDestination,
amendment: &ExecPolicyAmendment,
) -> Result<(), String> {
match destination {
PermissionSuggestionDestination::Session => sess
.services
.exec_policy
.add_amendment_to_current_policy(amendment)
.await
.map_err(|err| format!("failed to cache session prefix rule: {err}")),
PermissionSuggestionDestination::UserSettings => {
let codex_home = sess.codex_home().await;
sess.services
.exec_policy
.append_amendment_and_update(&codex_home, amendment)
.await
.map_err(|err| format!("failed to persist user prefix rule: {err}"))
}
PermissionSuggestionDestination::ProjectSettings => {
let config = turn_context.config.as_ref();
let project_codex_home = resolve_project_root(&config.config_layer_stack, &config.cwd)
.await
.map_err(|err| format!("failed to resolve project root for rules file: {err}"))?
.join(".codex")
.to_path_buf();
tokio::fs::create_dir_all(project_codex_home.join("rules"))
.await
.map_err(|err| format!("failed to create project rules directory: {err}"))?;
sess.services
.exec_policy
.append_amendment_and_update(&project_codex_home, amendment)
.await
.map_err(|err| format!("failed to persist project prefix rule: {err}"))
}
}
}
pub(crate) async fn run_post_tool_use_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,

View File

@@ -16,18 +16,13 @@
//! 3. We do **not** walk past the project root.
use crate::config::Config;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use codex_app_server_protocol::ConfigLayerSource;
use crate::config_loader::resolve_project_root;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path;
use std::io;
use toml::Value as TomlValue;
use tracing::error;
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
@@ -226,51 +221,14 @@ pub async fn discover_project_doc_paths(
dir = AbsolutePathBuf::try_from(canon)?;
}
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in config.config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
continue;
}
merge_toml_values(&mut merged, &layer.config);
}
let project_root_markers = match project_root_markers_from_config(&merged) {
Ok(Some(markers)) => markers,
Ok(None) => default_project_root_markers(),
Err(err) => {
tracing::warn!("invalid project_root_markers: {err}");
default_project_root_markers()
}
};
let mut project_root = None;
if !project_root_markers.is_empty() {
for ancestor in dir.ancestors() {
for marker in &project_root_markers {
let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
let marker_exists = match fs.get_metadata(&marker_path).await {
Ok(_) => true,
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
};
if marker_exists {
project_root = Some(AbsolutePathBuf::try_from(ancestor.to_path_buf())?);
break;
}
}
if project_root.is_some() {
break;
}
}
}
let project_root = resolve_project_root(&config.config_layer_stack, &dir).await?;
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
let search_dirs: Vec<AbsolutePathBuf> = if project_root != dir {
let mut dirs = Vec::new();
let mut cursor = dir.clone();
loop {
dirs.push(cursor.clone());
if cursor == root {
if cursor == project_root {
break;
}
let Some(parent) = cursor.parent() else {

View File

@@ -77,6 +77,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
hook_command: String,
additional_permissions: Option<PermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::codex::Session>,
@@ -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(&params.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(&params.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,
@@ -515,6 +520,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(),

View File

@@ -314,6 +314,7 @@ impl ToolHandler for UnifiedExecHandler {
.exec_command(
ExecCommandRequest {
command,
hook_command: args.cmd,
process_id,
yield_time_ms,
max_output_tokens,

View File

@@ -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<NetworkProxy>,
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,47 @@ 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}")),
permission_suggestions: Vec::new(),
},
)
.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 +439,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.to_path_buf(),
@@ -590,7 +635,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 {

View File

@@ -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

View File

@@ -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,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;
@@ -43,6 +46,12 @@ pub(crate) struct OrchestratorRunResult<Out> {
pub deferred_network_approval: Option<DeferredNetworkApproval>,
}
struct ApprovalTelemetry<'a> {
otel: &'a SessionTelemetry,
tool_name: &'a str,
call_id: &'a str,
}
impl ToolOrchestrator {
pub fn new() -> Self {
Self {
@@ -114,9 +123,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 +133,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,39 +153,25 @@ 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);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
"rejected by user".to_string()
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {}
NetworkPolicyRuleAction::Deny => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
let decision = Self::request_approval(
tool,
req,
tool_ctx.call_id.as_str(),
approval_ctx,
turn_ctx,
ApprovalTelemetry {
otel: &otel,
tool_name: otel_tn,
call_id: otel_ci,
},
}
)
.await?;
Self::enforce_approval_decision(
tool_ctx.session.as_ref(),
guardian_review_id.as_deref(),
decision,
)
.await?;
already_approved = true;
}
}
@@ -301,39 +298,25 @@ 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);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
} else {
"rejected by user".to_string()
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {}
NetworkPolicyRuleAction::Deny => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
let decision = Self::request_approval(
tool,
req,
&format!("{}:retry", tool_ctx.call_id),
approval_ctx,
turn_ctx,
ApprovalTelemetry {
otel: &otel,
tool_name: otel_tn,
call_id: otel_ci,
},
}
)
.await?;
Self::enforce_approval_decision(
tool_ctx.session.as_ref(),
guardian_review_id.as_deref(),
decision,
)
.await?;
}
let escalated_attempt = SandboxAttempt {
@@ -370,6 +353,98 @@ 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<Rq, Out, T>(
tool: &mut T,
req: &Rq,
permission_request_run_id: &str,
approval_ctx: ApprovalCtx<'_>,
turn_ctx: &crate::codex::TurnContext,
telemetry: ApprovalTelemetry<'_>,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
if let Some(permission_request) = tool.permission_request_payload(req, &approval_ctx) {
match run_permission_request_hooks(
approval_ctx.session,
approval_ctx.turn,
permission_request_run_id,
permission_request,
)
.await
{
Some(PermissionRequestDecision::Allow { .. }) => {
telemetry.otel.tool_decision(
telemetry.tool_name,
telemetry.call_id,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
return Ok(ReviewDecision::Approved);
}
Some(PermissionRequestDecision::Deny { message }) => {
telemetry.otel.tool_decision(
telemetry.tool_name,
telemetry.call_id,
&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
};
telemetry.otel.tool_decision(
telemetry.tool_name,
telemetry.call_id,
&decision,
otel_source,
);
Ok(decision)
}
// Normalizes approval outcomes from hooks, guardian, and user prompts into
// the orchestrator's `Result` flow so both initial and retry approvals use
// the same rejection and timeout handling.
async fn enforce_approval_decision(
session: &crate::codex::Session,
guardian_review_id: Option<&str>,
decision: ReviewDecision,
) -> Result<(), ToolError> {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id {
guardian_rejection_message(session, review_id).await
} else {
"rejected by user".to_string()
};
Err(ToolError::Rejected(reason))
}
ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())),
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => Ok(()),
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => Ok(()),
NetworkPolicyRuleAction::Deny => {
Err(ToolError::Rejected("rejected by user".to_string()))
}
},
}
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {

View File

@@ -23,14 +23,17 @@ 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;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::approval_permission_suggestions;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_hooks::PermissionSuggestionDestination;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
@@ -44,6 +47,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
@@ -197,6 +201,26 @@ impl Approvable<ShellRequest> for ShellRuntime {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(
&self,
req: &ShellRequest,
approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
let permission_suggestions = approval_permission_suggestions(
approval_ctx.network_approval_context.as_ref(),
req.exec_approval_requirement
.proposed_execpolicy_amendment(),
req.additional_permissions.as_ref(),
&[PermissionSuggestionDestination::UserSettings],
);
Some(PermissionRequestPayload {
tool_name: "Bash".to_string(),
command: req.hook_command.clone(),
description: req.justification.clone(),
permission_suggestions,
})
}
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
}
@@ -212,6 +236,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
Some(NetworkApprovalSpec {
network: req.network.clone(),
mode: NetworkApprovalMode::Immediate,
command: req.hook_command.clone(),
})
}

View File

@@ -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;
@@ -320,6 +323,7 @@ enum DecisionSource {
struct PromptDecision {
decision: ReviewDecision,
guardian_review_id: Option<String>,
rejection_message: Option<String>,
}
fn execve_prompt_is_rejected_by_policy(
@@ -394,11 +398,45 @@ 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,
permission_suggestions: Vec::new(),
};
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,
@@ -413,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,
@@ -432,6 +473,7 @@ impl CoreShellActionProvider {
PromptDecision {
decision,
guardian_review_id: None,
rejection_message: None,
}
})
.await)
@@ -487,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

View File

@@ -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<Value> = std::fs::read_to_string(&log_path)
.with_context(|| format!("read hook log at {}", log_path.display()))?
.lines()
.map(serde_json::from_str)
.collect::<serde_json::Result<_>>()
.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")"#;

View File

@@ -20,18 +20,21 @@ 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;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::approval_permission_suggestions;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_hooks::PermissionSuggestionDestination;
use codex_network_proxy::NetworkProxy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -49,6 +52,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub hook_command: String,
pub process_id: i32,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
@@ -177,6 +181,26 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(
&self,
req: &UnifiedExecRequest,
approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
let permission_suggestions = approval_permission_suggestions(
approval_ctx.network_approval_context.as_ref(),
req.exec_approval_requirement
.proposed_execpolicy_amendment(),
req.additional_permissions.as_ref(),
&[PermissionSuggestionDestination::UserSettings],
);
Some(PermissionRequestPayload {
tool_name: "Bash".to_string(),
command: req.hook_command.clone(),
description: req.justification.clone(),
permission_suggestions,
})
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
}
@@ -192,6 +216,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
Some(NetworkApprovalSpec {
network: req.network.clone(),
mode: NetworkApprovalMode::Deferred,
command: req.hook_command.clone(),
})
}

View File

@@ -10,14 +10,21 @@ use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::state::SessionServices;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_hooks::PermissionSuggestion;
use codex_hooks::PermissionSuggestionBehavior;
use codex_hooks::PermissionSuggestionDestination;
use codex_hooks::PermissionSuggestionRule;
use codex_hooks::PermissionSuggestionType;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::error::CodexErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ReviewDecision;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
@@ -131,6 +138,59 @@ pub(crate) struct ApprovalCtx<'a> {
pub network_approval_context: Option<NetworkApprovalContext>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionRequestPayload {
pub tool_name: String,
pub command: String,
pub description: Option<String>,
pub permission_suggestions: Vec<PermissionSuggestion>,
}
pub(crate) fn exec_policy_permission_suggestions(
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
destinations: &[PermissionSuggestionDestination],
) -> Vec<PermissionSuggestion> {
proposed_execpolicy_amendment
.into_iter()
.flat_map(|amendment| {
destinations
.iter()
.cloned()
.map(move |destination| PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: amendment.command.clone(),
}],
behavior: PermissionSuggestionBehavior::Allow,
destination,
})
})
.collect()
}
pub(crate) fn approval_permission_suggestions(
network_approval_context: Option<&NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
additional_permissions: Option<&PermissionProfile>,
destinations: &[PermissionSuggestionDestination],
) -> Vec<PermissionSuggestion> {
let available_decisions = ExecApprovalRequestEvent::default_available_decisions(
network_approval_context,
proposed_execpolicy_amendment,
/*proposed_network_policy_amendments*/ None,
additional_permissions,
);
let allows_execpolicy_amendment = available_decisions
.iter()
.any(|decision| matches!(decision, ReviewDecision::ApprovedExecpolicyAmendment { .. }));
if allows_execpolicy_amendment {
exec_policy_permission_suggestions(proposed_execpolicy_amendment, destinations)
} else {
Vec::new()
}
}
// Specifies what tool orchestrator should do with a given tool call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExecApprovalRequirement {
@@ -273,6 +333,16 @@ pub(crate) trait Approvable<Req> {
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,
_approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
None
}
/// Decide we can request an approval for no-sandbox execution.
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {

View File

@@ -1,7 +1,17 @@
use super::*;
use crate::sandboxing::SandboxPermissions;
use codex_hooks::PermissionSuggestion;
use codex_hooks::PermissionSuggestionDestination;
use codex_hooks::PermissionSuggestionRule;
use codex_hooks::PermissionSuggestionType;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
@@ -108,3 +118,75 @@ fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() {
SandboxOverride::BypassSandboxFirstAttempt
);
}
#[test]
fn command_approval_execpolicy_amendment_maps_to_user_settings_suggestion() {
let suggestions = approval_permission_suggestions(
/*network_approval_context*/ None,
Some(&ExecPolicyAmendment::new(vec![
"rm".to_string(),
"-rf".to_string(),
"node_modules".to_string(),
])),
/*additional_permissions*/ None,
&[PermissionSuggestionDestination::UserSettings],
);
assert_eq!(
suggestions,
vec![PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: vec![
"rm".to_string(),
"-rf".to_string(),
"node_modules".to_string(),
],
}],
behavior: PermissionSuggestionBehavior::Allow,
destination: PermissionSuggestionDestination::UserSettings,
}]
);
}
#[test]
fn command_approval_with_additional_permissions_has_no_persistent_suggestions() {
let suggestions = approval_permission_suggestions(
/*network_approval_context*/ None,
Some(&ExecPolicyAmendment::new(vec![
"cat".to_string(),
"/tmp/secret".to_string(),
])),
Some(&PermissionProfile {
network: None,
file_system: Some(FileSystemPermissions {
read: Some(vec![
AbsolutePathBuf::from_absolute_path("/tmp/secret")
.expect("/tmp/secret should be an absolute path"),
]),
write: None,
}),
}),
&[PermissionSuggestionDestination::UserSettings],
);
assert_eq!(suggestions, Vec::<PermissionSuggestion>::new());
}
#[test]
fn network_approval_with_execpolicy_amendment_has_no_persistent_suggestions() {
let suggestions = approval_permission_suggestions(
Some(&NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
}),
Some(&ExecPolicyAmendment::new(vec![
"curl".to_string(),
"https://example.com".to_string(),
])),
/*additional_permissions*/ None,
&[PermissionSuggestionDestination::UserSettings],
);
assert_eq!(suggestions, Vec::<PermissionSuggestion>::new());
}

View File

@@ -88,6 +88,7 @@ impl UnifiedExecContext {
#[derive(Debug)]
pub(crate) struct ExecCommandRequest {
pub command: Vec<String>,
pub hook_command: String,
pub process_id: i32,
pub yield_time_ms: u64,
pub max_output_tokens: Option<usize>,

View File

@@ -677,6 +677,7 @@ impl UnifiedExecProcessManager {
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
hook_command: request.hook_command.clone(),
process_id: request.process_id,
cwd,
env,

View File

@@ -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,148 @@ 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 == "allow_selected_session":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow",
"updatedPermissions": [
suggestion
for suggestion in payload.get("permission_suggestions", [])
if suggestion.get("destination") == "session"
]
}}
}}
}}))
elif mode == "allow_selected_project":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow",
"updatedPermissions": [
suggestion
for suggestion in payload.get("permission_suggestions", [])
if suggestion.get("destination") == "projectSettings"
]
}}
}}
}}))
elif mode == "allow_selected_user":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow",
"updatedPermissions": [
suggestion
for suggestion in payload.get("permission_suggestions", [])
if suggestion.get("destination") == "userSettings"
]
}}
}}
}}))
elif mode == "allow_unoffered_permission":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow",
"updatedPermissions": [{{
"type": "addRules",
"rules": [{{
"type": "prefixRule",
"command": ["curl"]
}}],
"behavior": "allow",
"destination": "userSettings"
}}]
}}
}}
}}))
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 +554,54 @@ fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>>
.collect()
}
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
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());
assert!(hook_input.get("permission_suggestions").is_none());
}
fn assert_single_permission_request_hook_input(
home: &Path,
command: &str,
description: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
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 assert_permission_request_hook_suggestions(hook_input: &Value, expected: Value) {
assert_eq!(
hook_input["permission_suggestions"], expected,
"permission request hook should include expected suggestions"
);
}
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("post_tool_use_hook_log.jsonl"))
.context("read post tool use hook log")?
@@ -1005,6 +1210,721 @@ 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 = 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_eq!(hook_inputs[0]["tool_input"]["description"], Value::Null);
assert_permission_request_hook_suggestions(
&hook_inputs[0],
serde_json::json!([
{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f", marker.display().to_string()],
}],
"behavior": "allow",
"destination": "userSettings",
}
]),
);
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"
);
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_eq!(hook_inputs[0]["tool_input"]["description"], justification);
assert_permission_request_hook_suggestions(
&hook_inputs[0],
serde_json::json!([
{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f", marker.display().to_string()],
}],
"behavior": "allow",
"destination": "userSettings",
}
]),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_request_hook_persists_selected_user_exec_rule() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id_first = "permissionrequest-exec-rule-first";
let call_id_second = "permissionrequest-exec-rule-second";
let marker = std::env::temp_dir().join("permissionrequest-exec-rule-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_first,
"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 persisted the exec rule"),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
core_test_support::responses::ev_function_call(
call_id_second,
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-3"),
]),
sse(vec![
ev_response_created("resp-4"),
ev_assistant_message("msg-2", "the persisted exec rule was reused"),
ev_completed("resp-4"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_permission_request_hook(
home,
Some(PERMISSION_REQUEST_HOOK_MATCHER),
"allow_selected_user",
PERMISSION_REQUEST_ALLOW_REASON,
) {
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 marker for first run")?;
test.submit_turn_with_policies(
"run the exec command and persist the suggested permission",
AskForApproval::OnRequest,
codex_protocol::protocol::SandboxPolicy::new_read_only_policy(),
)
.await?;
assert!(
!marker.exists(),
"first exec command should remove marker file"
);
fs::write(&marker, "seed").context("create exec command marker for second run")?;
test.submit_turn_with_policies(
"run the exec command again with the persisted permission",
AskForApproval::OnRequest,
codex_protocol::protocol::SandboxPolicy::new_read_only_policy(),
)
.await?;
assert!(
!marker.exists(),
"second exec command should remove marker file"
);
let requests = responses.requests();
assert_eq!(requests.len(), 4);
requests[1].function_call_output(call_id_first);
requests[3].function_call_output(call_id_second);
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
assert_eq!(
hook_inputs.len(),
1,
"persisted exec rule should bypass the second permission hook"
);
let rules_path = test.codex_home_path().join("rules").join("default.rules");
let rules_contents = fs::read_to_string(&rules_path)
.with_context(|| format!("read {}", rules_path.display()))?;
let marker_json =
serde_json::to_string(&marker.display().to_string()).context("serialize marker path")?;
let expected_rule =
format!(r#"prefix_rule(pattern=["rm", "-f", {marker_json}], decision="allow")"#);
assert!(
rules_contents.contains(&expected_rule),
"expected {rules_path:?} to contain {expected_rule}, got:\n{rules_contents}"
);
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"
);
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_eq!(
hook_inputs[0]["tool_input"]["description"],
"network-access http://codex-network-test.invalid:80"
);
assert!(hook_inputs[0].get("permission_suggestions").is_none());
test.codex.submit(Op::Shutdown {}).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_request_hook_sees_exec_command_network_retry_suggestions() -> 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-exec-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!({
"cmd": command,
"login": true,
"sandbox_permissions": "require_escalated",
});
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 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.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");
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?;
test.submit_turn_with_policies(
"run the exec 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 exec 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 exec network approval hook to bypass the approval prompt"
);
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_eq!(
hook_inputs[0]["tool_input"]["description"],
"Network access to \"codex-network-test.invalid\" is blocked by policy."
);
assert!(hook_inputs[0].get("permission_suggestions").is_none());
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(()));

View File

@@ -0,0 +1,158 @@
{
"$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"
},
"PermissionSuggestion": {
"properties": {
"behavior": {
"$ref": "#/definitions/PermissionSuggestionBehavior"
},
"destination": {
"$ref": "#/definitions/PermissionSuggestionDestination"
},
"rules": {
"items": {
"$ref": "#/definitions/PermissionSuggestionRule"
},
"type": "array"
},
"type": {
"$ref": "#/definitions/PermissionSuggestionType"
}
},
"required": [
"behavior",
"destination",
"rules",
"type"
],
"type": "object"
},
"PermissionSuggestionBehavior": {
"enum": [
"allow",
"deny",
"ask"
],
"type": "string"
},
"PermissionSuggestionDestination": {
"enum": [
"session",
"projectSettings",
"userSettings"
],
"type": "string"
},
"PermissionSuggestionRule": {
"oneOf": [
{
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"prefixRule"
],
"type": "string"
}
},
"required": [
"command",
"type"
],
"type": "object"
}
]
},
"PermissionSuggestionType": {
"enum": [
"addRules"
],
"type": "string"
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "PermissionRequest",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"permission_suggestions": {
"items": {
"$ref": "#/definitions/PermissionSuggestion"
},
"type": "array"
},
"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"
}

View File

@@ -0,0 +1,177 @@
{
"$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,
"items": {
"$ref": "#/definitions/PermissionSuggestion"
},
"type": "array"
}
},
"required": [
"behavior"
],
"type": "object"
},
"PermissionRequestHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"decision": {
"allOf": [
{
"$ref": "#/definitions/PermissionRequestDecisionWire"
}
],
"default": null
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
}
},
"required": [
"hookEventName"
],
"type": "object"
},
"PermissionSuggestion": {
"properties": {
"behavior": {
"$ref": "#/definitions/PermissionSuggestionBehavior"
},
"destination": {
"$ref": "#/definitions/PermissionSuggestionDestination"
},
"rules": {
"items": {
"$ref": "#/definitions/PermissionSuggestionRule"
},
"type": "array"
},
"type": {
"$ref": "#/definitions/PermissionSuggestionType"
}
},
"required": [
"behavior",
"destination",
"rules",
"type"
],
"type": "object"
},
"PermissionSuggestionBehavior": {
"enum": [
"allow",
"deny",
"ask"
],
"type": "string"
},
"PermissionSuggestionDestination": {
"enum": [
"session",
"projectSettings",
"userSettings"
],
"type": "string"
},
"PermissionSuggestionRule": {
"oneOf": [
{
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"prefixRule"
],
"type": "string"
}
},
"required": [
"command",
"type"
],
"type": "object"
}
]
},
"PermissionSuggestionType": {
"enum": [
"addRules"
],
"type": "string"
}
},
"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"
}

View File

@@ -11,6 +11,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -5,6 +5,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -5,6 +5,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -11,6 +11,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<HookRunSummary> {
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,

View File

@@ -19,6 +19,23 @@ pub(crate) struct PreToolUseOutput {
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PermissionRequestDecision {
Allow {
updated_permissions: Vec<crate::events::permission_request::PermissionSuggestion>,
},
Deny {
message: String,
},
}
#[derive(Debug, Clone)]
pub(crate) struct PermissionRequestOutput {
pub universal: UniversalOutput,
pub decision: Option<PermissionRequestDecision>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PostToolUseOutput {
pub universal: UniversalOutput,
@@ -48,6 +65,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 +135,29 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
})
}
pub(crate) fn parse_permission_request(stdout: &str) -> Option<PermissionRequestOutput> {
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<PostToolUseOutput> {
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
let universal = UniversalOutput::from(wire.universal);
@@ -235,6 +278,18 @@ fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<Str
}
}
fn unsupported_permission_request_universal(universal: &UniversalOutput) -> Option<String> {
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<String> {
if universal.suppress_output {
Some("PostToolUse hook returned unsupported suppressOutput".to_string())
@@ -243,6 +298,40 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<St
}
}
fn unsupported_permission_request_hook_specific_output(
decision: Option<&PermissionRequestDecisionWire>,
) -> Option<String> {
let decision = decision?;
if decision.updated_input.is_some() {
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
} else if matches!(decision.behavior, PermissionRequestBehaviorWire::Deny)
&& decision.updated_permissions.is_some()
{
Some("PermissionRequest hook returned updatedPermissions for deny decision".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 {
updated_permissions: decision.updated_permissions.clone().unwrap_or_default(),
},
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<String> {
@@ -334,3 +423,135 @@ fn trimmed_reason(reason: &str) -> Option<String> {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::PermissionRequestDecision;
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_accepts_updated_permissions_for_allow_decision() {
let parsed = parse_permission_request(
&json!({
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedPermissions": [{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f"]
}],
"behavior": "allow",
"destination": "userSettings"
}]
}
}
})
.to_string(),
)
.expect("permission request hook output should parse");
assert_eq!(
parsed.decision,
Some(PermissionRequestDecision::Allow {
updated_permissions: vec![crate::events::permission_request::PermissionSuggestion {
suggestion_type:
crate::events::permission_request::PermissionSuggestionType::AddRules,
rules: vec![
crate::events::permission_request::PermissionSuggestionRule::PrefixRule {
command: vec!["rm".to_string(), "-f".to_string()],
},
],
behavior:
crate::events::permission_request::PermissionSuggestionBehavior::Allow,
destination: crate::events::permission_request::PermissionSuggestionDestination::UserSettings,
}],
})
);
}
#[test]
fn permission_request_rejects_updated_permissions_for_deny_decision() {
let parsed = parse_permission_request(
&json!({
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"updatedPermissions": [{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f"]
}],
"behavior": "allow",
"destination": "userSettings"
}]
}
}
})
.to_string(),
)
.expect("permission request hook output should parse");
assert_eq!(
parsed.invalid_reason,
Some(
"PermissionRequest hook returned updatedPermissions for deny decision".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())
);
}
}

View File

@@ -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");

View File

@@ -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,
}
}

View File

@@ -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;

View File

@@ -0,0 +1,501 @@
//! 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 allow if at
//! least one handler allowed the request, and accumulate the selected
//! permission updates, 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;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSuggestion {
#[serde(rename = "type")]
pub suggestion_type: PermissionSuggestionType,
pub rules: Vec<PermissionSuggestionRule>,
pub behavior: PermissionSuggestionBehavior,
pub destination: PermissionSuggestionDestination,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum PermissionSuggestionType {
AddRules,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionSuggestionBehavior {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub enum PermissionSuggestionDestination {
#[serde(rename = "session")]
Session,
#[serde(rename = "projectSettings")]
ProjectSettings,
#[serde(rename = "userSettings")]
UserSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PermissionSuggestionRule {
PrefixRule { command: Vec<String> },
}
#[derive(Debug, Clone)]
pub struct PermissionRequestRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub tool_name: String,
pub run_id_suffix: String,
pub command: String,
pub description: Option<String>,
pub permission_suggestions: Vec<PermissionSuggestion>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionRequestDecision {
Allow {
updated_permissions: Vec<PermissionSuggestion>,
},
Deny {
message: String,
},
}
#[derive(Debug)]
pub struct PermissionRequestOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub decision: Option<PermissionRequestDecision>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PermissionRequestHandlerData {
decision: Option<PermissionRequestDecision>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &PermissionRequestRequest,
) -> Vec<HookRunSummary> {
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 mut results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
for result in &mut results {
if let Some(invalid_reason) = invalid_permission_updates(
result.data.decision.as_ref(),
&request.permission_suggestions,
) {
result.completed.run.status = HookRunStatus::Failed;
result.completed.run.entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_reason,
});
result.data.decision = None;
}
}
// Any deny wins immediately. Otherwise, accumulate the selected permission
// updates from matching allow decisions in precedence order.
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 allow if any handler allowed the request and accumulate the
/// distinct selected permission updates in precedence order.
fn resolve_permission_request_decision<'a>(
decisions: impl IntoIterator<Item = &'a PermissionRequestDecision>,
) -> Option<PermissionRequestDecision> {
let mut saw_allow = false;
let mut updated_permissions = Vec::new();
for decision in decisions {
match decision {
PermissionRequestDecision::Allow {
updated_permissions: selected_permissions,
} => {
saw_allow = true;
for permission in selected_permissions {
if !updated_permissions.contains(permission) {
updated_permissions.push(permission.clone());
}
}
}
PermissionRequestDecision::Deny { message } => {
return Some(PermissionRequestDecision::Deny {
message: message.clone(),
});
}
}
}
saw_allow.then_some(PermissionRequestDecision::Allow {
updated_permissions,
})
}
fn invalid_permission_updates(
decision: Option<&PermissionRequestDecision>,
offered_permissions: &[PermissionSuggestion],
) -> Option<String> {
let PermissionRequestDecision::Allow {
updated_permissions,
} = decision?
else {
return None;
};
if updated_permissions
.iter()
.any(|permission| !offered_permissions.contains(permission))
{
Some("PermissionRequest hook returned updatedPermissions that were not offered".to_string())
} else {
None
}
}
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(),
},
permission_suggestions: (!request.permission_suggestions.is_empty())
.then_some(request.permission_suggestions.clone()),
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<PermissionRequestHandlerData> {
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 {
updated_permissions,
} => {
decision = Some(PermissionRequestDecision::Allow {
updated_permissions,
});
}
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::PermissionSuggestion;
use super::PermissionSuggestionBehavior;
use super::PermissionSuggestionDestination;
use super::PermissionSuggestionRule;
use super::PermissionSuggestionType;
use super::invalid_permission_updates;
use super::resolve_permission_request_decision;
#[test]
fn permission_request_deny_overrides_earlier_allow() {
let decisions = [
PermissionRequestDecision::Allow {
updated_permissions: vec![],
},
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 {
updated_permissions: vec![],
},
PermissionRequestDecision::Allow {
updated_permissions: vec![],
},
];
assert_eq!(
resolve_permission_request_decision(decisions.iter()),
Some(PermissionRequestDecision::Allow {
updated_permissions: vec![],
})
);
}
#[test]
fn permission_request_returns_none_when_no_handler_decides() {
let decisions = Vec::<PermissionRequestDecision>::new();
assert_eq!(resolve_permission_request_decision(decisions.iter()), None);
}
#[test]
fn permission_request_accumulates_distinct_updated_permissions() {
let permission = PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: vec!["rm".to_string(), "-f".to_string()],
}],
behavior: PermissionSuggestionBehavior::Allow,
destination: PermissionSuggestionDestination::UserSettings,
};
let decisions = [
PermissionRequestDecision::Allow {
updated_permissions: vec![permission.clone()],
},
PermissionRequestDecision::Allow {
updated_permissions: vec![permission.clone()],
},
];
assert_eq!(
resolve_permission_request_decision(decisions.iter()),
Some(PermissionRequestDecision::Allow {
updated_permissions: vec![permission],
})
);
}
#[test]
fn permission_request_rejects_unoffered_updated_permissions() {
let offered = vec![PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: vec!["rm".to_string()],
}],
behavior: PermissionSuggestionBehavior::Allow,
destination: PermissionSuggestionDestination::UserSettings,
}];
let selected = PermissionRequestDecision::Allow {
updated_permissions: vec![PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: vec!["curl".to_string()],
}],
behavior: PermissionSuggestionBehavior::Allow,
destination: PermissionSuggestionDestination::UserSettings,
}],
};
assert_eq!(
invalid_permission_updates(Some(&selected), &offered),
Some(
"PermissionRequest hook returned updatedPermissions that were not offered"
.to_string()
)
);
}
}

View File

@@ -5,6 +5,14 @@ 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::permission_request::PermissionSuggestion;
pub use events::permission_request::PermissionSuggestionBehavior;
pub use events::permission_request::PermissionSuggestionDestination;
pub use events::permission_request::PermissionSuggestionRule;
pub use events::permission_request::PermissionSuggestionType;
pub use events::post_tool_use::PostToolUseOutcome;
pub use events::post_tool_use::PostToolUseRequest;
pub use events::pre_tool_use::PreToolUseOutcome;

View File

@@ -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<codex_protocol::protocol::HookRunSummary> {
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
}

View File

@@ -12,9 +12,13 @@ use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use crate::events::permission_request::PermissionSuggestion;
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 +73,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 +115,55 @@ pub(crate) struct PostToolUseCommandOutputWire {
pub hook_specific_output: Option<PostToolUseHookSpecificOutputWire>,
}
#[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<PermissionRequestHookSpecificOutputWire>,
}
#[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<PermissionRequestDecisionWire>,
}
#[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<Value>,
#[serde(default)]
pub updated_permissions: Option<Vec<PermissionSuggestion>>,
#[serde(default)]
pub message: Option<String>,
/// 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 +236,36 @@ 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<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,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_suggestions: Option<Vec<PermissionSuggestion>>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -358,6 +443,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PostToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE),
schema_json::<PermissionRequestCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
schema_json::<PermissionRequestCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
schema_json::<PreToolUseCommandInput>()?,
@@ -461,10 +554,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 +617,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 +648,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 +695,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 +727,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::<PermissionRequestCommandInput>()
.expect("serialize permission request input schema"),
)
.expect("parse permission request input schema");
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
@@ -625,7 +742,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"]

View File

@@ -1573,6 +1573,7 @@ pub enum EventMsg {
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
PreToolUse,
PermissionRequest,
PostToolUse,
SessionStart,
UserPromptSubmit,

View File

@@ -1064,6 +1064,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",

View File

@@ -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",