mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
15 Commits
starr/full
...
abhinav/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6fa98d992 | ||
|
|
ea59e08790 | ||
|
|
7bc1c3610e | ||
|
|
ab4cb3ecef | ||
|
|
7cbb4c1ab9 | ||
|
|
ba66b94d55 | ||
|
|
578294dfc5 | ||
|
|
32a652ef3d | ||
|
|
d6a1ba97b9 | ||
|
|
c7d184680d | ||
|
|
f6f2d63a6f | ||
|
|
f9dfd3d50c | ||
|
|
550e0bc131 | ||
|
|
565a651a54 | ||
|
|
da2567023f |
@@ -1777,6 +1777,7 @@
|
||||
"warning",
|
||||
"stop",
|
||||
"feedback",
|
||||
"reason",
|
||||
"context",
|
||||
"error"
|
||||
],
|
||||
|
||||
@@ -9861,6 +9861,7 @@
|
||||
"warning",
|
||||
"stop",
|
||||
"feedback",
|
||||
"reason",
|
||||
"context",
|
||||
"error"
|
||||
],
|
||||
|
||||
@@ -6470,6 +6470,7 @@
|
||||
"warning",
|
||||
"stop",
|
||||
"feedback",
|
||||
"reason",
|
||||
"context",
|
||||
"error"
|
||||
],
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"warning",
|
||||
"stop",
|
||||
"feedback",
|
||||
"reason",
|
||||
"context",
|
||||
"error"
|
||||
],
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"warning",
|
||||
"stop",
|
||||
"feedback",
|
||||
"reason",
|
||||
"context",
|
||||
"error"
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -458,7 +458,6 @@ impl ToolRegistry {
|
||||
outcome.additional_contexts.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let replacement_text = if outcome.should_stop {
|
||||
Some(
|
||||
outcome
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1590,6 +1590,7 @@ pub enum HookOutputEntryKind {
|
||||
Warning,
|
||||
Stop,
|
||||
Feedback,
|
||||
Reason,
|
||||
Context,
|
||||
Error,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: rendered
|
||||
---
|
||||
• PreToolUse hook (completed)
|
||||
reason: approved by policy
|
||||
@@ -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;
|
||||
|
||||
@@ -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: ",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user