Compare commits

...

15 Commits

Author SHA1 Message Date
Abhinav Vedmala
e6fa98d992 Ignore PreToolUse allow decisions 2026-05-04 15:24:48 -07:00
Abhinav Vedmala
ea59e08790 Keep PreToolUse allow monotonic 2026-05-04 15:15:45 -07:00
Abhinav Vedmala
7bc1c3610e Honor PreToolUse ask after network denials 2026-05-04 11:22:38 -07:00
Abhinav Vedmala
ab4cb3ecef Inline PreToolUse reason format arg 2026-05-04 11:07:27 -07:00
Abhinav Vedmala
7cbb4c1ab9 Merge branch 'abhinav/pretooluse-additional-context' into abhinav/pretooluse-approval-decisions 2026-05-04 11:05:41 -07:00
Abhinav
ba66b94d55 Merge branch 'main' into abhinav/pretooluse-additional-context 2026-05-04 10:53:13 -07:00
Abhinav Vedmala
578294dfc5 Clarify PreToolUse permission reasons 2026-05-03 11:32:42 -07:00
Abhinav Vedmala
32a652ef3d Clarify MCP pre-tool-use approval precedence 2026-05-02 14:45:40 -07:00
Abhinav Vedmala
d6a1ba97b9 Keep MCP allow decisions behind ARC 2026-05-02 14:34:15 -07:00
Abhinav Vedmala
c7d184680d Honor pre-tool-use ask decisions 2026-05-02 14:25:55 -07:00
Abhinav Vedmala
f6f2d63a6f support PreToolUse approval decisions 2026-05-02 13:28:43 -07:00
Abhinav Vedmala
f9dfd3d50c Keep PreToolUse core boundary narrow 2026-05-01 16:18:20 -07:00
Abhinav Vedmala
550e0bc131 Record PreToolUse context at hook boundary 2026-05-01 16:12:54 -07:00
Abhinav Vedmala
565a651a54 preserve legacy PreToolUse blocks with context 2026-05-01 15:39:54 -07:00
Abhinav Vedmala
da2567023f support PreToolUse additional context 2026-05-01 15:13:10 -07:00
30 changed files with 976 additions and 172 deletions

View File

@@ -1777,6 +1777,7 @@
"warning",
"stop",
"feedback",
"reason",
"context",
"error"
],

View File

@@ -9861,6 +9861,7 @@
"warning",
"stop",
"feedback",
"reason",
"context",
"error"
],

View File

@@ -6470,6 +6470,7 @@
"warning",
"stop",
"feedback",
"reason",
"context",
"error"
],

View File

@@ -51,6 +51,7 @@
"warning",
"stop",
"feedback",
"reason",
"context",
"error"
],

View File

@@ -51,6 +51,7 @@
"warning",
"stop",
"feedback",
"reason",
"context",
"error"
],

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 HookOutputEntryKind = "warning" | "stop" | "feedback" | "context" | "error";
export type HookOutputEntryKind = "warning" | "stop" | "feedback" | "reason" | "context" | "error";

View File

@@ -494,7 +494,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookOutputEntryKind from CoreHookOutputEntryKind {
Warning, Stop, Feedback, Context, Error
Warning, Stop, Feedback, Reason, Context, Error
}
);

View File

@@ -150,7 +150,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
permission_mode: hook_permission_mode(turn_context),
tool_name: tool_name.name().to_string(),
matcher_aliases: tool_name.matcher_aliases().to_vec(),
tool_use_id,
tool_use_id: tool_use_id.clone(),
tool_input: tool_input.clone(),
};
let hooks = sess.hooks();
@@ -161,8 +161,14 @@ pub(crate) async fn run_pre_tool_use_hooks(
hook_events,
should_block,
block_reason,
additional_contexts,
permission_decision,
} = hooks.run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
record_additional_contexts(sess, turn_context, additional_contexts).await;
if let Some(permission_decision) = permission_decision {
turn_context.record_pre_tool_use_permission_decision(tool_use_id, permission_decision);
}
if should_block {
block_reason.map(|reason| {

View File

@@ -39,6 +39,7 @@ use codex_analytics::build_track_events_context;
use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PreToolUsePermissionDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState;
@@ -1020,20 +1021,24 @@ async fn maybe_request_mcp_tool_approval(
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
if mcp_permission_prompt_is_auto_approved(
let pre_tool_use_permission_decision = turn_context.pre_tool_use_permission_decision(call_id);
let pre_tool_use_asks_user = matches!(
pre_tool_use_permission_decision.as_ref(),
Some(PreToolUsePermissionDecision::Ask { .. })
);
let auto_approved_by_permissions = mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
&turn_context.permission_profile(),
McpPermissionPromptAutoApproveContext {
approvals_reviewer: Some(turn_context.config.approvals_reviewer),
tool_approval_mode: Some(approval_mode),
},
) {
return None;
}
);
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = requires_mcp_tool_approval(annotations);
if !approval_required && approval_mode != AppToolApproval::Prompt {
let approval_required = !auto_approved_by_permissions
&& (requires_mcp_tool_approval(annotations) || approval_mode == AppToolApproval::Prompt);
if !approval_required && !pre_tool_use_asks_user {
return None;
}
@@ -1050,7 +1055,8 @@ async fn maybe_request_mcp_tool_approval(
)
.await
{
ArcMonitorOutcome::Ok => return None,
ArcMonitorOutcome::Ok if !pre_tool_use_asks_user => return None,
ArcMonitorOutcome::Ok => {}
ArcMonitorOutcome::AskUser(reason) => {
monitor_reason = Some(reason);
}
@@ -1061,39 +1067,47 @@ async fn maybe_request_mcp_tool_approval(
}
}
}
let must_prompt_user = monitor_reason.is_some() || pre_tool_use_asks_user;
if auto_approved_by_policy && approval_required && !must_prompt_user {
return None;
}
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
let persistent_approval_key =
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
if let Some(key) = session_approval_key.as_ref()
if !must_prompt_user
&& !pre_tool_use_asks_user
&& let Some(key) = session_approval_key.as_ref()
&& mcp_tool_approval_is_remembered(sess, key).await
{
return Some(McpToolApprovalDecision::Accept);
}
match run_permission_request_hooks(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_input: invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return Some(McpToolApprovalDecision::Accept);
if !must_prompt_user && !pre_tool_use_asks_user {
match run_permission_request_hooks(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_input: invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return Some(McpToolApprovalDecision::Accept);
}
Some(PermissionRequestDecision::Deny { message }) => {
return Some(McpToolApprovalDecision::Decline {
message: Some(message),
});
}
None => {}
}
Some(PermissionRequestDecision::Deny { message }) => {
return Some(McpToolApprovalDecision::Decline {
message: Some(message),
});
}
None => {}
}
let tool_call_mcp_elicitation_enabled = turn_context
@@ -1101,7 +1115,7 @@ async fn maybe_request_mcp_tool_approval(
.features
.enabled(Feature::ToolCallMcpElicitation);
if routes_approval_to_guardian(turn_context) {
if routes_approval_to_guardian(turn_context) && !must_prompt_user {
let review_id = new_guardian_review_id();
let decision = review_approval_request(
sess,
@@ -1123,9 +1137,14 @@ async fn maybe_request_mcp_tool_approval(
return Some(decision);
}
let (prompt_session_approval_key, prompt_persistent_approval_key) = if must_prompt_user {
(None, None)
} else {
(session_approval_key, persistent_approval_key)
};
let prompt_options = mcp_tool_approval_prompt_options(
session_approval_key.as_ref(),
persistent_approval_key.as_ref(),
prompt_session_approval_key.as_ref(),
prompt_persistent_approval_key.as_ref(),
tool_call_mcp_elicitation_enabled,
);
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
@@ -1150,8 +1169,12 @@ async fn maybe_request_mcp_tool_approval(
.as_ref()
.map(|rendered_template| rendered_template.question.as_str()),
);
question.question =
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
let pre_tool_use_reason = match &pre_tool_use_permission_decision {
Some(PreToolUsePermissionDecision::Ask { reason }) => reason.as_deref(),
None => None,
};
let prompt_reason = monitor_reason.as_deref().or(pre_tool_use_reason);
question.question = mcp_tool_approval_question_text(question.question, prompt_reason);
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
@@ -1169,7 +1192,7 @@ async fn maybe_request_mcp_tool_approval(
tool_params_display: tool_params_display.as_deref(),
question,
message_override: rendered_template.as_ref().and_then(|rendered_template| {
monitor_reason
prompt_reason
.is_none()
.then_some(rendered_template.elicitation_message.as_str())
}),
@@ -1186,8 +1209,8 @@ async fn maybe_request_mcp_tool_approval(
sess,
turn_context,
&decision,
session_approval_key,
persistent_approval_key,
prompt_session_approval_key,
prompt_persistent_approval_key,
)
.await;
return Some(decision);
@@ -1207,8 +1230,8 @@ async fn maybe_request_mcp_tool_approval(
sess,
turn_context,
&decision,
session_approval_key,
persistent_approval_key,
prompt_session_approval_key,
prompt_persistent_approval_key,
)
.await;
Some(decision)

View File

@@ -146,6 +146,7 @@ pub(super) async fn spawn_review_thread(
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
pre_tool_use_permission_decisions: Default::default(),
truncation_policy: model_info.truncation_policy.into(),
turn_metadata_state,
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::config::GhostSnapshotConfig;
use codex_hooks::PreToolUsePermissionDecision;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -7,6 +8,8 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -87,6 +90,8 @@ pub(crate) struct TurnContext {
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
pub(crate) pre_tool_use_permission_decisions:
Arc<StdMutex<HashMap<String, PreToolUsePermissionDecision>>>,
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
pub(crate) turn_skills: TurnSkillsContext,
pub(crate) turn_timing_state: Arc<TurnTimingState>,
@@ -106,6 +111,28 @@ impl TurnContext {
self.permission_profile.network_sandbox_policy()
}
pub(crate) fn record_pre_tool_use_permission_decision(
&self,
tool_use_id: String,
decision: PreToolUsePermissionDecision,
) {
self.pre_tool_use_permission_decisions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(tool_use_id, decision);
}
pub(crate) fn pre_tool_use_permission_decision(
&self,
tool_use_id: &str,
) -> Option<PreToolUsePermissionDecision> {
self.pre_tool_use_permission_decisions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(tool_use_id)
.cloned()
}
pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> {
self.environments.first()
}
@@ -258,6 +285,7 @@ impl TurnContext {
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy,
dynamic_tools: self.dynamic_tools.clone(),
pre_tool_use_permission_decisions: self.pre_tool_use_permission_decisions.clone(),
turn_metadata_state: self.turn_metadata_state.clone(),
turn_skills: self.turn_skills.clone(),
turn_timing_state: Arc::clone(&self.turn_timing_state),
@@ -548,6 +576,7 @@ impl Session {
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: model_info.truncation_policy.into(),
dynamic_tools: session_configuration.dynamic_tools.clone(),
pre_tool_use_permission_decisions: Arc::new(StdMutex::new(HashMap::new())),
turn_metadata_state,
turn_skills: TurnSkillsContext::new(skills_outcome),
turn_timing_state: Arc::new(TurnTimingState::default()),

View File

@@ -11,6 +11,7 @@ use crate::session::session::Session;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ToolError;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PreToolUsePermissionDecision;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::NetworkDecision;
@@ -51,6 +52,7 @@ pub(crate) struct NetworkApprovalSpec {
pub mode: NetworkApprovalMode,
pub trigger: GuardianNetworkAccessTrigger,
pub command: String,
pub pre_tool_use_permission_decision: Option<PreToolUsePermissionDecision>,
}
#[derive(Clone, Debug)]
@@ -224,6 +226,7 @@ struct ActiveNetworkApprovalCall {
turn_id: String,
trigger: GuardianNetworkAccessTrigger,
command: String,
pre_tool_use_permission_decision: Option<PreToolUsePermissionDecision>,
cancellation_token: CancellationToken,
}
@@ -267,6 +270,7 @@ impl NetworkApprovalService {
turn_id: String,
trigger: GuardianNetworkAccessTrigger,
command: String,
pre_tool_use_permission_decision: Option<PreToolUsePermissionDecision>,
cancellation_token: CancellationToken,
) {
let mut calls = self.calls.lock().await;
@@ -278,6 +282,7 @@ impl NetworkApprovalService {
turn_id,
trigger,
command,
pre_tool_use_permission_decision,
cancellation_token,
}),
);
@@ -401,17 +406,25 @@ impl NetworkApprovalService {
NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,
};
let key = HostApprovalKey::from_request(&request, protocol);
let owner_call = self.resolve_single_active_call().await;
let pre_tool_use_permission_decision = owner_call
.as_ref()
.and_then(|call| call.pre_tool_use_permission_decision.as_ref());
let pre_tool_use_asks_user = matches!(
pre_tool_use_permission_decision,
Some(PreToolUsePermissionDecision::Ask { .. })
);
{
let denied_hosts = self.session_denied_hosts.lock().await;
if denied_hosts.contains(&key) {
if denied_hosts.contains(&key) && !pre_tool_use_asks_user {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
}
{
let approved_hosts = self.session_approved_hosts.lock().await;
if approved_hosts.contains(&key) {
if approved_hosts.contains(&key) && !pre_tool_use_asks_user {
return NetworkDecision::Allow;
}
}
@@ -454,7 +467,6 @@ impl NetworkApprovalService {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
let owner_call = self.resolve_single_active_call().await;
let network_approval_context = NetworkApprovalContext {
host: request.host.clone(),
protocol,
@@ -464,13 +476,14 @@ impl NetworkApprovalService {
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::bash(command, Some(format!("network-access {target}"))),
)
.await
if !pre_tool_use_asks_user
&& let Some(permission_request_decision) = run_permission_request_hooks(
&session,
&turn_context,
&guardian_approval_id,
PermissionRequestPayload::bash(command, Some(format!("network-access {target}"))),
)
.await
{
match permission_request_decision {
PermissionRequestDecision::Allow => {
@@ -496,8 +509,9 @@ impl NetworkApprovalService {
}
}
}
let use_guardian = routes_approval_to_guardian(&turn_context);
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let guardian_review_id = (routes_approval_to_guardian(&turn_context)
&& !pre_tool_use_asks_user)
.then(new_guardian_review_id);
let approval_decision = if let Some(review_id) = guardian_review_id.clone() {
review_approval_request(
&session,
@@ -526,7 +540,12 @@ impl NetworkApprovalService {
/*approval_id*/ None,
prompt_command,
turn_context.cwd.clone(),
Some(prompt_reason),
match pre_tool_use_permission_decision {
Some(PreToolUsePermissionDecision::Ask { reason }) => {
reason.clone().or(Some(prompt_reason))
}
None => Some(prompt_reason),
},
Some(network_approval_context.clone()),
/*proposed_execpolicy_amendment*/ None,
/*additional_permissions*/ None,
@@ -710,6 +729,7 @@ pub(crate) async fn begin_network_approval(
mode,
trigger,
command,
pre_tool_use_permission_decision,
} = spec?;
if !managed_network_active || network.is_none() {
return None;
@@ -725,6 +745,7 @@ pub(crate) async fn begin_network_approval(
turn_id.to_string(),
trigger,
command,
pre_tool_use_permission_decision,
cancellation_token.clone(),
)
.await;

View File

@@ -238,6 +238,7 @@ async fn register_call_with_default_shell_trigger(
tty: None,
},
"curl https://example.com".to_string(),
/*pre_tool_use_permission_decision*/ None,
cancellation_token.clone(),
)
.await;
@@ -264,6 +265,7 @@ async fn active_call_preserves_triggering_command_context() {
"turn-1".to_string(),
expected.clone(),
"curl https://example.com".to_string(),
/*pre_tool_use_permission_decision*/ None,
CancellationToken::new(),
)
.await;

View File

@@ -27,6 +27,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::default_exec_approval_requirement;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PreToolUsePermissionDecision;
use codex_otel::ToolDecisionSource;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -138,7 +139,19 @@ impl ToolOrchestrator {
let otel_tn = &tool_ctx.tool_name;
let otel_ci = &tool_ctx.call_id;
let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await;
let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review;
let pre_tool_use_permission_decision = tool_ctx
.turn
.pre_tool_use_permission_decision(&tool_ctx.call_id);
let pre_tool_use_asks_user = matches!(
pre_tool_use_permission_decision.as_ref(),
Some(PreToolUsePermissionDecision::Ask { .. })
);
let pre_tool_use_reason = match &pre_tool_use_permission_decision {
Some(PreToolUsePermissionDecision::Ask { reason }) => reason.clone(),
None => None,
};
let use_guardian = (routes_approval_to_guardian(turn_ctx) || strict_auto_review)
&& !pre_tool_use_asks_user;
// 1) Approval
let mut already_approved = false;
@@ -150,15 +163,23 @@ impl ToolOrchestrator {
});
match requirement {
ExecApprovalRequirement::Skip { .. } => {
if strict_auto_review {
let guardian_review_id = Some(new_guardian_review_id());
if !strict_auto_review && !pre_tool_use_asks_user {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
} else {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: None,
retry_reason: pre_tool_use_reason.clone(),
network_approval_context: None,
bypass_approval_cache: pre_tool_use_asks_user,
};
let decision = Self::request_approval(
tool,
@@ -167,19 +188,11 @@ impl ToolOrchestrator {
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ false,
&otel,
)
.await?;
Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision)
.await?;
already_approved = true;
} else {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
}
}
ExecApprovalRequirement::Forbidden { reason } => {
@@ -192,8 +205,9 @@ impl ToolOrchestrator {
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: reason,
retry_reason: pre_tool_use_reason.clone().or(reason),
network_approval_context: None,
bypass_approval_cache: pre_tool_use_asks_user,
};
let decision = Self::request_approval(
tool,
@@ -201,8 +215,8 @@ impl ToolOrchestrator {
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&otel,
/*evaluate_permission_request_hooks*/
!strict_auto_review && !pre_tool_use_asks_user,
)
.await?;
@@ -325,8 +339,9 @@ impl ToolOrchestrator {
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: Some(retry_reason),
retry_reason: pre_tool_use_reason.clone().or(Some(retry_reason)),
network_approval_context: network_approval_context.clone(),
bypass_approval_cache: pre_tool_use_asks_user,
};
let permission_request_run_id = format!("{}:retry", tool_ctx.call_id);
@@ -336,8 +351,8 @@ impl ToolOrchestrator {
&permission_request_run_id,
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&otel,
/*evaluate_permission_request_hooks*/
!strict_auto_review && !pre_tool_use_asks_user,
)
.await?;
@@ -389,11 +404,11 @@ impl ToolOrchestrator {
approval_ctx: ApprovalCtx<'_>,
tool_ctx: &ToolCtx,
evaluate_permission_request_hooks: bool,
otel: &codex_otel::SessionTelemetry,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
let otel = approval_ctx.turn.session_telemetry.clone();
if evaluate_permission_request_hooks
&& let Some(permission_request) = tool.permission_request_payload(req)
{
@@ -428,7 +443,6 @@ impl ToolOrchestrator {
None => {}
}
}
let otel_source = if approval_ctx.guardian_review_id.is_some() {
ToolDecisionSource::AutomatedReviewer
} else {

View File

@@ -458,7 +458,6 @@ impl ToolRegistry {
outcome.additional_contexts.clone(),
)
.await;
let replacement_text = if outcome.should_stop {
Some(
outcome

View File

@@ -119,7 +119,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
}
if req.permissions_preapproved && retry_reason.is_none() {
if req.permissions_preapproved && retry_reason.is_none() && !ctx.bypass_approval_cache {
return ReviewDecision::Approved;
}
if let Some(reason) = retry_reason {
@@ -135,20 +135,19 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
return rx_approve.await.unwrap_or_default();
}
with_cached_approval(
&session.services,
"apply_patch",
approval_keys,
|| async move {
let rx_approve = session
.request_patch_approval(
turn, call_id, changes, /*reason*/ None, /*grant_root*/ None,
)
.await;
rx_approve.await.unwrap_or_default()
},
)
.await
let fetch = || async move {
let rx_approve = session
.request_patch_approval(
turn, call_id, changes, /*reason*/ None, /*grant_root*/ None,
)
.await;
rx_approve.await.unwrap_or_default()
};
if ctx.bypass_approval_cache {
fetch().await
} else {
with_cached_approval(&session.services, "apply_patch", approval_keys, fetch).await
}
})
}

View File

@@ -175,7 +175,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
)
.await;
}
with_cached_approval(&session.services, "shell", keys, move || async move {
let fetch = move || async move {
let available_decisions = None;
session
.request_command_approval(
@@ -193,8 +193,12 @@ impl Approvable<ShellRequest> for ShellRuntime {
available_decisions,
)
.await
})
.await
};
if ctx.bypass_approval_cache {
fetch().await
} else {
with_cached_approval(&session.services, "shell", keys, fetch).await
}
})
}
@@ -236,6 +240,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
tty: None,
},
command: req.hook_command.clone(),
pre_tool_use_permission_decision: ctx
.turn
.pre_tool_use_permission_decision(&ctx.call_id),
})
}

View File

@@ -28,6 +28,7 @@ use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PreToolUsePermissionDecision;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -403,38 +404,49 @@ impl CoreShellActionProvider {
let call_id = self.call_id.clone();
let approval_id = Some(Uuid::new_v4().to_string());
let source = self.tool_name;
let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id);
let pre_tool_use_permission_decision =
self.turn.pre_tool_use_permission_decision(&self.call_id);
let pre_tool_use_asks_user = matches!(
pre_tool_use_permission_decision.as_ref(),
Some(PreToolUsePermissionDecision::Ask { .. })
);
let guardian_review_id = (routes_approval_to_guardian(&turn) && !pre_tool_use_asks_user)
.then(new_guardian_review_id);
Ok(stopwatch
.pause_for(async move {
// 1) Run PermissionRequest hooks
let permission_request = PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(&command),
/*description*/ None,
);
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
match run_permission_request_hooks(
&session,
&turn,
&effective_approval_id,
permission_request,
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return PromptDecision {
decision: ReviewDecision::Approved,
guardian_review_id: None,
rejection_message: None,
};
// 1) Run PermissionRequest hooks when PreToolUse did not already
// require the user to answer this tool call.
if !pre_tool_use_asks_user {
let permission_request = PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(&command),
/*description*/ None,
);
let effective_approval_id =
approval_id.clone().unwrap_or_else(|| call_id.clone());
match run_permission_request_hooks(
&session,
&turn,
&effective_approval_id,
permission_request,
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return PromptDecision {
decision: ReviewDecision::Approved,
guardian_review_id: None,
rejection_message: None,
};
}
Some(PermissionRequestDecision::Deny { message }) => {
return PromptDecision {
decision: ReviewDecision::Denied,
guardian_review_id: None,
rejection_message: Some(message),
};
}
None => {}
}
Some(PermissionRequestDecision::Deny { message }) => {
return PromptDecision {
decision: ReviewDecision::Denied,
guardian_review_id: None,
rejection_message: Some(message),
};
}
None => {}
}
// 2) Route to Guardian if configured
@@ -469,7 +481,10 @@ impl CoreShellActionProvider {
approval_id,
command,
workdir.clone(),
/*reason*/ None,
match pre_tool_use_permission_decision {
Some(PreToolUsePermissionDecision::Ask { reason }) => reason,
None => None,
},
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
additional_permissions,

View File

@@ -168,7 +168,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
)
.await;
}
with_cached_approval(&session.services, "unified_exec", keys, || async move {
let fetch = || async move {
let available_decisions = None;
session
.request_command_approval(
@@ -186,8 +186,12 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
available_decisions,
)
.await
})
.await
};
if ctx.bypass_approval_cache {
fetch().await
} else {
with_cached_approval(&session.services, "unified_exec", keys, fetch).await
}
})
}
@@ -235,6 +239,9 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
tty: Some(req.tty),
},
command: req.hook_command.clone(),
pre_tool_use_permission_decision: ctx
.turn
.pre_tool_use_permission_decision(&ctx.call_id),
})
}

View File

@@ -130,6 +130,7 @@ pub(crate) struct ApprovalCtx<'a> {
pub guardian_review_id: Option<String>,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
pub bypass_approval_cache: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -13,6 +13,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::user_input::UserInput;
@@ -33,6 +34,7 @@ use core_test_support::skip_if_windows;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -67,6 +69,38 @@ fn network_workspace_write_profile() -> PermissionProfile {
)
}
async fn submit_turn_without_waiting(
test: &core_test_support::test_codex::TestCodex,
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
) -> Result<()> {
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, test.cwd.path());
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: prompt.into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy,
approvals_reviewer: None,
sandbox_policy,
permission_profile,
model: test.session_configured.model.clone(),
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
Ok(())
}
fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> {
let script_path = home.join("stop_hook.py");
let log_path = home.join("stop_hook_log.jsonl");
@@ -237,6 +271,38 @@ if mode == "json_deny":
"permissionDecisionReason": reason
}}
}}))
elif mode == "json_allow":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason
}}
}}))
elif mode == "json_ask":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": reason
}}
}}))
elif mode == "context":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"additionalContext": reason
}}
}}))
elif mode == "json_deny_with_context":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
"additionalContext": reason
}}
}}))
elif mode == "exit_2":
sys.stderr.write(reason + "\n")
raise SystemExit(2)
@@ -1374,6 +1440,135 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
Ok(())
}
#[tokio::test]
async fn pre_tool_use_ask_prompts_user_with_hook_reason() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-ask-shell-command";
let marker = std::env::temp_dir().join("pretooluse-ask-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"),
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", "pre tool use asked first"),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_function_call(
"pretooluse-ask-shell-command-again",
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-3"),
]),
sse(vec![
ev_response_created("resp-4"),
ev_assistant_message("msg-2", "pre tool use asked again"),
ev_completed("resp-4"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "json_ask", "please confirm")
{
panic!("failed to write pre tool use 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 pre tool use marker")?;
submit_turn_without_waiting(
&test,
"run the shell command after pre tool use asks",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
)
.await?;
let approval_event = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ExecApprovalRequest(_))
})
.await;
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
panic!("expected pre tool use ask to prompt the user");
};
assert_eq!(approval.reason.as_deref(), Some("please confirm"));
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedForSession,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert!(
!marker.exists(),
"approved command should remove marker file"
);
fs::write(&marker, "seed again").context("recreate pre tool use marker")?;
submit_turn_without_waiting(
&test,
"run the same shell command after pre tool use asks again",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
)
.await?;
let second_approval_event = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ExecApprovalRequest(_))
})
.await;
let EventMsg::ExecApprovalRequest(second_approval) = second_approval_event else {
panic!("expected pre tool use ask to bypass cached approval");
};
assert_eq!(second_approval.reason.as_deref(), Some("please confirm"));
test.codex
.submit(Op::ExecApproval {
id: second_approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert!(
!marker.exists(),
"second approved command should remove marker file"
);
Ok(())
}
#[tokio::test]
async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1831,6 +2026,158 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-context";
let command = "printf pre-tool-output".to_string();
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", "pre hook context observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let pre_context = "Remember the bash pre-tool note.";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "context", pre_context)
{
panic!("failed to write pre tool use 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?;
test.submit_turn("run the shell command with pre hook")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[1]
.message_input_texts("developer")
.contains(&pre_context.to_string()),
"follow-up request should include pre tool use additional context",
);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("pre-tool-output"),
"shell command output should still reach the model",
);
Ok(())
}
#[tokio::test]
async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-blocked-context";
let marker = std::env::temp_dir().join("pretooluse-shell-command-blocked-context-marker");
let command = format!("printf blocked > {}", 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", "blocked pre hook context observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let pre_context = "blocked by pre hook with context";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny_with_context", pre_context)
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover pre tool use marker")?;
}
test.submit_turn_with_permission_profile(
"run the blocked shell command with pre hook context",
PermissionProfile::Disabled,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[1]
.message_input_texts("developer")
.contains(&pre_context.to_string()),
"follow-up request should include blocked pre tool use additional context",
);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("Command blocked by PreToolUse hook: blocked by pre hook with context"),
"blocked tool output should still surface the hook reason",
);
assert!(
!marker.exists(),
"blocked command should not create marker file"
);
Ok(())
}
#[tokio::test]
async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -52,12 +52,35 @@
"type": "object"
},
"PreToolUsePermissionDecisionWire": {
"enum": [
"allow",
"deny",
"ask"
],
"type": "string"
"oneOf": [
{
"enum": [
"deny"
],
"type": "string"
},
{
"description": "Accepted for compatibility.",
"enum": [
"allow"
],
"type": "string"
},
{
"description": "Always requests explicit human-user confirmation.",
"enum": [
"ask"
],
"type": "string"
},
{
"description": "Reserved for the Codex exec integration.\n\nCodex does not implement this decision yet.",
"enum": [
"defer"
],
"type": "string"
}
]
}
},
"properties": {

View File

@@ -16,9 +16,16 @@ pub(crate) struct SessionStartOutput {
pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub permission_decision: Option<PreToolUsePermissionDecision>,
pub additional_context: Option<String>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PreToolUsePermissionDecision {
Ask { reason: Option<String> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PermissionRequestDecision {
Allow,
@@ -92,11 +99,12 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
} = parse_json(stdout)?;
let universal = UniversalOutput::from(universal_wire);
let hook_specific_output = hook_specific_output.as_ref();
let additional_context =
hook_specific_output.and_then(|output| output.additional_context.clone());
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
output.permission_decision.is_some()
|| output.permission_decision_reason.is_some()
|| output.updated_input.is_some()
|| output.additional_context.is_some()
});
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
if use_hook_specific_decision {
@@ -123,10 +131,17 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
} else {
None
};
let permission_decision = if invalid_reason.is_none() {
hook_specific_output.and_then(pre_tool_use_permission_decision)
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
permission_decision,
additional_context,
invalid_reason,
})
}
@@ -339,33 +354,27 @@ fn unsupported_pre_tool_use_hook_specific_output(
) -> Option<String> {
if output.updated_input.is_some() {
Some("PreToolUse hook returned unsupported updatedInput".to_string())
} else if output
.additional_context
.as_deref()
.and_then(trimmed_reason)
.is_some()
{
Some("PreToolUse hook returned unsupported additionalContext".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
}
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
}
Some(PreToolUsePermissionDecisionWire::Deny) => {
match output.permission_decision.as_ref() {
Some(PreToolUsePermissionDecisionWire::Allow) => None,
Some(
decision @ (PreToolUsePermissionDecisionWire::Ask
| PreToolUsePermissionDecisionWire::Deny),
) => {
if output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason)
.is_none()
{
Some(invalid_pre_tool_use_reason_message())
Some(invalid_pre_tool_use_reason_message(decision))
} else {
None
}
}
Some(PreToolUsePermissionDecisionWire::Defer) => {
Some("PreToolUse hook returned unsupported permissionDecision:defer".to_string())
}
None => {
if output.permission_decision_reason.is_some() {
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
@@ -402,9 +411,32 @@ fn unsupported_pre_tool_use_legacy_decision(
}
}
fn invalid_pre_tool_use_reason_message() -> String {
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
.to_string()
fn invalid_pre_tool_use_reason_message(decision: &PreToolUsePermissionDecisionWire) -> String {
let decision = match decision {
PreToolUsePermissionDecisionWire::Allow => "allow",
PreToolUsePermissionDecisionWire::Deny => "deny",
PreToolUsePermissionDecisionWire::Ask => "ask",
PreToolUsePermissionDecisionWire::Defer => "defer",
};
format!(
"PreToolUse hook returned permissionDecision:{decision} without a non-empty permissionDecisionReason"
)
}
fn pre_tool_use_permission_decision(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<PreToolUsePermissionDecision> {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => None,
Some(PreToolUsePermissionDecisionWire::Ask) => Some(PreToolUsePermissionDecision::Ask {
reason: output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason),
}),
Some(PreToolUsePermissionDecisionWire::Deny | PreToolUsePermissionDecisionWire::Defer)
| None => None,
}
}
fn trimmed_reason(reason: &str) -> Option<String> {
@@ -422,6 +454,7 @@ mod tests {
use serde_json::json;
use super::parse_permission_request;
use super::parse_pre_tool_use;
#[test]
fn permission_request_rejects_reserved_updated_input_field() {
@@ -491,4 +524,53 @@ mod tests {
Some("PermissionRequest hook returned unsupported interrupt:true".to_string())
);
}
#[test]
fn pre_tool_use_permission_decisions_require_reason() {
for decision in ["ask", "deny"] {
let parsed = parse_pre_tool_use(
&json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision
}
})
.to_string(),
)
.expect("pre tool use hook output should parse");
assert_eq!(
parsed.invalid_reason,
Some(format!(
"PreToolUse hook returned permissionDecision:{decision} without a non-empty permissionDecisionReason"
))
);
}
}
#[test]
fn pre_tool_use_allow_is_ignored() {
for stdout in [
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
})
.to_string(),
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "approved by hook"
}
})
.to_string(),
] {
let parsed =
parse_pre_tool_use(&stdout).expect("pre tool use hook output should parse");
assert_eq!(parsed.invalid_reason, None);
assert_eq!(parsed.permission_decision, None);
}
}
}

View File

@@ -16,6 +16,7 @@ use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::engine::output_parser::PreToolUsePermissionDecision as ParsedPermissionDecision;
use crate::schema::PreToolUseCommandInput;
#[derive(Debug, Clone)]
@@ -37,12 +38,24 @@ pub struct PreToolUseOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_block: bool,
pub block_reason: Option<String>,
pub permission_decision: Option<PreToolUsePermissionDecision>,
pub additional_contexts: Vec<String>,
}
/// Hook-authored approval guidance to apply later at concrete permission
/// boundaries for the same tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreToolUsePermissionDecision {
/// Require explicit human-user confirmation.
Ask { reason: Option<String> },
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PreToolUseHandlerData {
should_block: bool,
block_reason: Option<String>,
permission_decision: Option<PreToolUsePermissionDecision>,
additional_contexts_for_model: Vec<String>,
}
pub(crate) fn preview(
@@ -78,6 +91,8 @@ pub(crate) async fn run(
hook_events: Vec::new(),
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts: Vec::new(),
};
}
@@ -108,6 +123,16 @@ pub(crate) async fn run(
let block_reason = results
.iter()
.find_map(|result| result.data.block_reason.clone());
let permission_decision = resolve_permission_decision(
results
.iter()
.filter_map(|result| result.data.permission_decision.as_ref()),
);
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
PreToolUseOutcome {
hook_events: results
@@ -118,9 +143,17 @@ pub(crate) async fn run(
.collect(),
should_block,
block_reason,
permission_decision,
additional_contexts,
}
}
fn resolve_permission_decision<'a>(
decisions: impl IntoIterator<Item = &'a PreToolUsePermissionDecision>,
) -> Option<PreToolUsePermissionDecision> {
decisions.into_iter().last().cloned()
}
/// Serializes command stdin for a selected `PreToolUse` hook.
///
/// Handler selection may include internal matcher aliases, but hook stdin keeps
@@ -151,6 +184,8 @@ fn parse_completed(
let mut status = HookRunStatus::Completed;
let mut should_block = false;
let mut block_reason = None;
let mut permission_decision = None;
let mut additional_contexts_for_model = Vec::new();
match run_result.error.as_deref() {
Some(error) => {
@@ -177,14 +212,36 @@ fn parse_completed(
kind: HookOutputEntryKind::Error,
text: invalid_reason,
});
} else if let Some(reason) = parsed.block_reason {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
if let Some(additional_context) = parsed.additional_context {
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
if let Some(reason) = parsed.block_reason {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
}
if let Some(parsed_decision) = parsed.permission_decision {
let ParsedPermissionDecision::Ask { reason } = parsed_decision;
let decision = PreToolUsePermissionDecision::Ask {
reason: reason.clone(),
};
if let Some(reason) = reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Reason,
text: reason,
});
}
permission_decision = Some(decision);
}
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
@@ -238,6 +295,8 @@ fn parse_completed(
data: PreToolUseHandlerData {
should_block,
block_reason,
permission_decision,
additional_contexts_for_model,
},
}
}
@@ -247,6 +306,8 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToo
hook_events,
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts: Vec::new(),
}
}
@@ -262,6 +323,7 @@ mod tests {
use pretty_assertions::assert_eq;
use super::PreToolUseHandlerData;
use super::PreToolUsePermissionDecision;
use super::command_input_json;
use super::parse_completed;
use super::preview;
@@ -298,6 +360,8 @@ mod tests {
PreToolUseHandlerData {
should_block: true,
block_reason: Some("do not run that".to_string()),
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -327,6 +391,8 @@ mod tests {
PreToolUseHandlerData {
should_block: true,
block_reason: Some("do not run that".to_string()),
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -340,7 +406,44 @@ mod tests {
}
#[test]
fn unsupported_permission_decision_fails_open() {
fn deprecated_block_decision_with_additional_context_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","reason":"do not run that","hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"remember this"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: true,
block_reason: Some("do not run that".to_string()),
permission_decision: None,
additional_contexts_for_model: vec!["remember this".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "remember this".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "do not run that".to_string(),
},
]
);
}
#[test]
fn permission_decision_ask_is_user_visible_without_model_context() {
let parsed = parse_completed(
&handler(),
run_result(
@@ -356,6 +459,66 @@ mod tests {
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: Some(PreToolUsePermissionDecision::Ask {
reason: Some("please confirm".to_string()),
}),
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Reason,
text: "please confirm".to_string(),
}]
);
}
#[test]
fn permission_decision_allow_is_ignored() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"approved by policy"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(parsed.completed.run.entries, Vec::new());
}
#[test]
fn permission_decision_defer_stays_unimplemented() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -363,7 +526,7 @@ mod tests {
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(),
text: "PreToolUse hook returned unsupported permissionDecision:defer".to_string(),
}]
);
}
@@ -381,6 +544,8 @@ mod tests {
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -394,7 +559,7 @@ mod tests {
}
#[test]
fn unsupported_additional_context_fails_open() {
fn additional_context_is_recorded() {
let parsed = parse_completed(
&handler(),
run_result(
@@ -408,17 +573,25 @@ mod tests {
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
should_block: true,
block_reason: Some("do not run that".to_string()),
permission_decision: None,
additional_contexts_for_model: vec!["nope".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook returned unsupported additionalContext".to_string(),
}]
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "nope".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "do not run that".to_string(),
},
]
);
}
@@ -435,6 +608,8 @@ mod tests {
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
@@ -454,6 +629,8 @@ mod tests {
PreToolUseHandlerData {
should_block: false,
block_reason: None,
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -479,6 +656,8 @@ mod tests {
PreToolUseHandlerData {
should_block: true,
block_reason: Some("blocked by policy".to_string()),
permission_decision: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);

View File

@@ -34,6 +34,7 @@ pub use events::permission_request::PermissionRequestRequest;
pub use events::post_tool_use::PostToolUseOutcome;
pub use events::post_tool_use::PostToolUseRequest;
pub use events::pre_tool_use::PreToolUseOutcome;
pub use events::pre_tool_use::PreToolUsePermissionDecision;
pub use events::pre_tool_use::PreToolUseRequest;
pub use events::session_start::SessionStartOutcome;
pub use events::session_start::SessionStartRequest;

View File

@@ -194,12 +194,19 @@ pub(crate) struct PreToolUseHookSpecificOutputWire {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUsePermissionDecisionWire {
/// Accepted for compatibility.
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
/// Always requests explicit human-user confirmation.
#[serde(rename = "ask")]
Ask,
/// Reserved for the Codex exec integration.
///
/// Codex does not implement this decision yet.
#[serde(rename = "defer")]
Defer,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]

View File

@@ -1590,6 +1590,7 @@ pub enum HookOutputEntryKind {
Warning,
Stop,
Feedback,
Reason,
Context,
Error,
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: rendered
---
• PreToolUse hook (completed)
reason: approved by policy

View File

@@ -2230,6 +2230,34 @@ async fn blocked_and_failed_hooks_render_feedback_and_errors() {
);
}
#[tokio::test]
async fn completed_hook_renders_reason_output() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Reason,
text: "approved by policy".to_string(),
}],
),
);
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!("hook_completed_reason_history_snapshot", rendered);
assert!(
rendered.contains("PreToolUse hook (completed)\n reason: approved by policy"),
"expected completed hook reason: {rendered:?}"
);
}
#[tokio::test]
async fn completed_hook_with_output_flushes_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -701,6 +701,7 @@ fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str {
HookOutputEntryKind::Warning => "warning: ",
HookOutputEntryKind::Stop => "stop: ",
HookOutputEntryKind::Feedback => "feedback: ",
HookOutputEntryKind::Reason => "reason: ",
HookOutputEntryKind::Context => "hook context: ",
HookOutputEntryKind::Error => "error: ",
}