Compare commits

...

22 Commits

Author SHA1 Message Date
Abhinav Vedmala
cb3b6fadef Simplify PermissionRequest input rewrites 2026-05-06 14:01:45 -07:00
Abhinav
412d7d5b26 Merge branch 'abhinav/hooks-updated-input' into abhinav/hooks-updated-input-permission-request 2026-05-06 13:10:43 -07:00
Abhinav Vedmala
786882a0bc Log rewritten tool payloads 2026-05-06 13:05:15 -07:00
Abhinav Vedmala
04ae865e76 support rewrites for legacy shell handlers 2026-05-06 12:22:50 -07:00
Abhinav
108ecb0a89 Merge branch 'main' into abhinav/hooks-updated-input 2026-05-06 12:11:59 -07:00
Abhinav Vedmala
4af5d3e146 Merge cleaned PreToolUse base into PermissionRequest stack
# Conflicts:
#	codex-rs/core/src/tools/registry.rs
2026-05-06 11:48:24 -07:00
Abhinav Vedmala
bbfa80cda9 Keep base handler result ordering unchanged 2026-05-06 11:47:55 -07:00
Abhinav Vedmala
fa1b31a7b2 Merge documented PreToolUse base into PermissionRequest stack 2026-05-06 11:42:23 -07:00
Abhinav Vedmala
afa8cb9c4c Document hook input inversions 2026-05-06 11:42:20 -07:00
Abhinav Vedmala
0368d82457 Merge refreshed PreToolUse base into PermissionRequest stack 2026-05-06 11:25:40 -07:00
Abhinav Vedmala
f8dbb08834 Merge origin/main into hooks-updated-input pretool base 2026-05-06 11:25:35 -07:00
Abhinav Vedmala
70cdcb27a1 Support PermissionRequest input rewrites 2026-05-06 11:11:03 -07:00
Abhinav Vedmala
c92387f864 Use the PR merge base for the split 2026-05-06 11:10:55 -07:00
Abhinav Vedmala
2f423b129d Split out PermissionRequest rewrites 2026-05-06 11:08:56 -07:00
Abhinav Vedmala
a4176794dc Trust hook fixtures in rewrite tests 2026-05-06 11:03:56 -07:00
Abhinav
26651a0e62 Merge branch 'main' into abhinav/hooks-updated-input 2026-05-05 16:46:37 -07:00
Abhinav Vedmala
f2888eda0f Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	codex-rs/core/src/tools/handlers/unified_exec.rs
2026-05-05 15:16:01 -07:00
Abhinav Vedmala
607d2544f9 merge main into hooks-updated-input 2026-05-05 11:16:53 -07:00
Abhinav Vedmala
b1ea7125f6 simplify hook rewrite helpers 2026-05-05 11:10:57 -07:00
Abhinav Vedmala
7932199328 simplify hook input rewrites 2026-05-04 16:58:54 -07:00
Abhinav Vedmala
bcccc6c208 address hook rewrite review feedback 2026-04-30 16:51:14 -07:00
Abhinav Vedmala
61724ae083 support hook input rewrites 2026-04-30 16:34:32 -07:00
29 changed files with 1924 additions and 556 deletions

View File

@@ -4,6 +4,8 @@ use thiserror::Error;
pub enum FunctionCallError {
#[error("{0}")]
RespondToModel(String),
#[error("tool input rewritten by hook")]
UpdatedInput(serde_json::Value),
#[error("LocalShellCall without call_id or id")]
MissingLocalShellCallId,
#[error("Fatal error: {0}")]

View File

@@ -43,6 +43,11 @@ pub(crate) struct HookRuntimeOutcome {
pub additional_contexts: Vec<String>,
}
pub(crate) enum PreToolUseHookResult {
Continue { updated_input: Option<Value> },
Blocked(String),
}
pub(crate) enum PendingInputHookDisposition {
Accepted(Box<PendingInputRecord>),
Blocked { additional_contexts: Vec<String> },
@@ -140,7 +145,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
tool_use_id: String,
tool_name: &HookToolName,
tool_input: &Value,
) -> Option<String> {
) -> PreToolUseHookResult {
let request = PreToolUseRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
@@ -162,25 +167,32 @@ pub(crate) async fn run_pre_tool_use_hooks(
should_block,
block_reason,
additional_contexts,
updated_input,
} = 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 should_block {
block_reason.map(|reason| {
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
{
format!("Command blocked by PreToolUse hook: {reason}. Command: {command}")
} else {
format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
tool_name.name()
)
}
})
if !should_block {
return PreToolUseHookResult::Continue { updated_input };
}
let Some(reason) = block_reason else {
return PreToolUseHookResult::Continue {
updated_input: None,
};
};
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
{
PreToolUseHookResult::Blocked(format!(
"Command blocked by PreToolUse hook: {reason}. Command: {command}"
))
} else {
None
PreToolUseHookResult::Blocked(format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
tool_name.name()
))
}
}

View File

@@ -201,6 +201,7 @@ pub(crate) async fn handle_mcp_tool_call(
)
.await;
let mut invocation = invocation;
if let Some(decision) = maybe_request_mcp_tool_approval(
&sess,
turn_context,
@@ -212,7 +213,24 @@ pub(crate) async fn handle_mcp_tool_call(
)
.await
{
let tool_input = invocation
.arguments
.clone()
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
let result = match decision {
McpToolApprovalDecision::AcceptWithUpdatedInput(updated_input) => {
invocation.arguments = Some(updated_input);
return handle_approved_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
metadata.as_ref(),
request_meta,
mcp_app_resource_uri,
)
.await;
}
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::AcceptForSession
| McpToolApprovalDecision::AcceptAndRemember => {
@@ -279,8 +297,7 @@ pub(crate) async fn handle_mcp_tool_call(
return HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input: arguments_value
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
tool_input,
};
}
@@ -937,6 +954,7 @@ async fn maybe_track_codex_app_used(
#[derive(Debug, Clone, PartialEq, Eq)]
enum McpToolApprovalDecision {
Accept,
AcceptWithUpdatedInput(JsonValue),
AcceptForSession,
AcceptAndRemember,
Decline { message: Option<String> },
@@ -1199,21 +1217,34 @@ async fn maybe_request_mcp_tool_approval(
return Some(McpToolApprovalDecision::Accept);
}
let permission_request_tool_input = invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
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())),
tool_input: permission_request_tool_input.clone(),
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
Some(PermissionRequestDecision::Allow {
updated_input: Some(updated_input),
}) => {
if updated_input == permission_request_tool_input {
return Some(McpToolApprovalDecision::Accept);
}
return Some(McpToolApprovalDecision::AcceptWithUpdatedInput(
updated_input,
));
}
Some(PermissionRequestDecision::Allow {
updated_input: None,
}) => {
return Some(McpToolApprovalDecision::Accept);
}
Some(PermissionRequestDecision::Deny { message }) => {
@@ -1967,6 +1998,7 @@ async fn apply_mcp_tool_approval_decision(
}
}
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::AcceptWithUpdatedInput(_)
| McpToolApprovalDecision::Decline { .. }
| McpToolApprovalDecision::Cancel
| McpToolApprovalDecision::BlockedBySafetyMonitor(_) => {}

View File

@@ -2347,6 +2347,80 @@ async fn permission_request_hook_allows_mcp_tool_call() {
);
}
#[tokio::test]
async fn permission_request_hook_can_update_mcp_tool_input() {
let (mut session, turn_context) = make_session_and_context().await;
install_mcp_permission_request_hook(
&mut session,
&turn_context,
"mcp__memory__.*",
&serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": {
"entities": [{
"name": "Grace",
"entityType": "person"
}]
}
}
}
}),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "memory".to_string(),
tool: "create_entities".to_string(),
arguments: Some(serde_json::json!({
"entities": [{
"name": "Ada",
"entityType": "person"
}]
})),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(false),
Some(true),
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-mcp-hook",
&invocation,
"mcp__memory__create_entities",
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(
decision,
Some(McpToolApprovalDecision::AcceptWithUpdatedInput(
serde_json::json!({
"entities": [{
"name": "Grace",
"entityType": "person"
}]
})
))
);
}
#[tokio::test]
async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
let (mut session, turn_context) = make_session_and_context().await;

View File

@@ -1035,6 +1035,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
&tool_ctx,
turn.as_ref(),
AskForApproval::Never,
crate::tools::sandboxing::InitialApprovalState::Evaluate,
)
.await
.expect("probe runtime should succeed");

View File

@@ -336,6 +336,11 @@ pub(crate) async fn handle_output_item_done(
output.needs_follow_up = true;
}
Err(FunctionCallError::UpdatedInput(_)) => {
return Err(CodexErr::Fatal(
"updated tool input escaped dispatch".to_string(),
));
}
// A fatal error occurred; surface it back into history.
Err(FunctionCallError::Fatal(message)) => {
return Err(CodexErr::Fatal(message));

View File

@@ -353,6 +353,9 @@ impl ToolEmitter {
let result = Err(FunctionCallError::RespondToModel(normalized));
(event, result)
}
Err(ToolError::UpdatedInput(updated_input)) => {
return Err(FunctionCallError::UpdatedInput(updated_input));
}
};
self.emit(ctx, event).await;
result

View File

@@ -22,6 +22,8 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::PostToolUsePayload;
@@ -31,6 +33,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::runtimes::apply_patch::ApplyPatchRequest;
use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
use crate::tools::sandboxing::InitialApprovalState;
use crate::tools::sandboxing::ToolCtx;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
@@ -323,6 +326,32 @@ impl ToolHandler for ApplyPatchHandler {
})
}
// Hooks expose apply_patch through the stable `{ "command": ... }` shape,
// while the underlying tool stores the patch as either the function
// argument `input` or freeform custom-tool input.
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let patch = updated_hook_command(&updated_input)?;
invocation.payload = match invocation.payload {
ToolPayload::Function { arguments } => ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"apply_patch",
"input",
patch,
)?,
},
ToolPayload::Custom { .. } => ToolPayload::Custom {
input: patch.to_string(),
},
payload => payload,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -341,129 +370,143 @@ impl ToolHandler for ApplyPatchHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
tool_name,
payload,
..
} = invocation;
handle_apply_patch_invocation(invocation, InitialApprovalState::Evaluate).await
}
let patch_input = match payload {
ToolPayload::Function { arguments } => {
let args: ApplyPatchToolArgs = parse_arguments(&arguments)?;
args.input
}
ToolPayload::Custom { input } => input,
_ => {
return Err(FunctionCallError::RespondToModel(
"apply_patch handler received unsupported payload".to_string(),
));
}
};
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
handle_apply_patch_invocation(invocation, InitialApprovalState::AlreadyApproved).await
}
}
// Re-parse and verify the patch so we can compute changes and approval.
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(turn_environment) = turn.environments.primary() else {
async fn handle_apply_patch_invocation(
invocation: ToolInvocation,
initial_approval_state: InitialApprovalState,
) -> Result<ApplyPatchToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
tool_name,
payload,
..
} = invocation;
let patch_input = match payload {
ToolPayload::Function { arguments } => {
let args: ApplyPatchToolArgs = parse_arguments(&arguments)?;
args.input
}
ToolPayload::Custom { input } => input,
_ => {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
"apply_patch handler received unsupported payload".to_string(),
));
};
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
&command,
&cwd,
fs.as_ref(),
sandbox.as_ref(),
)
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
.await
{
InternalApplyPatchInvocation::Output(item) => {
let content = item?;
Ok(ApplyPatchToolOutput::from_text(content))
}
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter =
ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
Some(&tracker),
);
emitter.begin(event_ctx).await;
}
};
let req = ApplyPatchRequest {
action: apply.action,
file_paths,
changes,
exec_approval_requirement: apply.exec_approval_requirement,
additional_permissions: effective_additional_permissions
.additional_permissions,
permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
};
// Re-parse and verify the patch so we can compute changes and approval.
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(turn_environment) = turn.environments.primary() else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
};
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
&command,
&cwd,
fs.as_ref(),
sandbox.as_ref(),
)
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
.await
{
InternalApplyPatchInvocation::Output(item) => {
let content = item?;
Ok(ApplyPatchToolOutput::from_text(content))
}
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
Some(&tracker),
);
emitter.begin(event_ctx).await;
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ApplyPatchRuntime::new();
let tool_ctx = ToolCtx {
session: session.clone(),
turn: turn.clone(),
call_id: call_id.clone(),
tool_name: tool_name.display(),
};
let out = orchestrator
.run(
&mut runtime,
&req,
&tool_ctx,
turn.as_ref(),
turn.approval_policy.value(),
)
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
let req = ApplyPatchRequest {
action: apply.action,
file_paths,
changes,
exec_approval_requirement: apply.exec_approval_requirement,
additional_permissions: effective_additional_permissions
.additional_permissions,
permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ApplyPatchRuntime::new();
let tool_ctx = ToolCtx {
session: session.clone(),
turn: turn.clone(),
call_id: call_id.clone(),
tool_name: tool_name.display(),
};
let out = orchestrator
.run(
&mut runtime,
&req,
&tool_ctx,
turn.as_ref(),
&call_id,
Some(&tracker),
);
let content = emitter.finish(event_ctx, out).await?;
Ok(ApplyPatchToolOutput::from_text(content))
}
turn.approval_policy.value(),
initial_approval_state,
)
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
Some(&tracker),
);
let content = emitter.finish(event_ctx, out).await?;
Ok(ApplyPatchToolOutput::from_text(content))
}
}
codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
Err(FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: {parse_error}"
)))
}
codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
tracing::trace!("Failed to parse apply_patch input, {error:?}");
Err(FunctionCallError::RespondToModel(
"apply_patch handler received invalid patch input".to_string(),
))
}
codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => {
Err(FunctionCallError::RespondToModel(
"apply_patch handler received non-apply_patch input".to_string(),
))
}
}
codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
Err(FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: {parse_error}"
)))
}
codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
tracing::trace!("Failed to parse apply_patch input, {error:?}");
Err(FunctionCallError::RespondToModel(
"apply_patch handler received invalid patch input".to_string(),
))
}
codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => {
Err(FunctionCallError::RespondToModel(
"apply_patch handler received non-apply_patch input".to_string(),
))
}
}
}
@@ -542,6 +585,7 @@ pub(crate) async fn intercept_apply_patch(
&tool_ctx,
turn.as_ref(),
turn.approval_policy.value(),
InitialApprovalState::Evaluate,
)
.await
.map(|result| result.output);

View File

@@ -48,6 +48,29 @@ impl ToolHandler for McpHandler {
})
}
// MCP hooks expose the full arguments object, so rewrites replace the
// serialized raw argument payload wholesale rather than patching one
// handler-owned field.
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
invocation.payload = match invocation.payload {
ToolPayload::Mcp { server, tool, .. } => ToolPayload::Mcp {
server,
tool,
raw_arguments: serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten MCP arguments: {err}"
))
})?,
},
payload => payload,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,

View File

@@ -24,6 +24,7 @@ use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
@@ -81,6 +82,47 @@ where
parse_arguments(arguments)
}
fn updated_hook_command(updated_input: &Value) -> Result<&str, FunctionCallError> {
updated_input
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned updatedInput without string field `command`".to_string(),
)
})
}
fn rewrite_function_arguments(
arguments: &str,
tool_name: &str,
rewrite: impl FnOnce(&mut Map<String, Value>),
) -> Result<String, FunctionCallError> {
let mut arguments: Value = parse_arguments(arguments)?;
let Value::Object(arguments) = &mut arguments else {
return Err(FunctionCallError::RespondToModel(format!(
"{tool_name} arguments must be an object"
)));
};
rewrite(arguments);
serde_json::to_string(&arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten {tool_name} arguments: {err}"
))
})
}
fn rewrite_function_string_argument(
arguments: &str,
tool_name: &str,
field_name: &str,
value: &str,
) -> Result<String, FunctionCallError> {
rewrite_function_arguments(arguments, tool_name, |arguments| {
arguments.insert(field_name.to_string(), Value::String(value.to_string()));
})
}
fn resolve_workdir_base_path(
arguments: &str,
default_cwd: &AbsolutePathBuf,

View File

@@ -25,6 +25,9 @@ use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::handlers::rewrite_function_arguments;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::PostToolUsePayload;
@@ -34,6 +37,7 @@ use crate::tools::registry::ToolKind;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use crate::tools::sandboxing::InitialApprovalState;
use crate::tools::sandboxing::ToolCtx;
use codex_features::Feature;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -86,6 +90,35 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
.map(|params| params.command)
}
// Hooks expose legacy function shell tools as joined command strings, while
// their function payload stores argv. Split on the way back in to invert the
// hook-facing representation.
fn rewrite_shell_function_updated_hook_input(
mut invocation: ToolInvocation,
updated_input: JsonValue,
tool_name: &str,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(format!(
"hook input rewrite received unsupported {tool_name} payload"
)));
};
let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| {
arguments.insert(
"command".to_string(),
JsonValue::Array(command.into_iter().map(JsonValue::String).collect()),
);
})?,
};
Ok(invocation)
}
struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
@@ -98,6 +131,7 @@ struct RunExecLikeArgs {
call_id: String,
freeform: bool,
shell_runtime_backend: ShellRuntimeBackend,
initial_approval_state: InitialApprovalState,
}
impl ShellHandler {
@@ -123,6 +157,50 @@ impl ShellHandler {
arg0: None,
}
}
async fn handle_with_permission_request_hooks(
invocation: ToolInvocation,
initial_approval_state: InitialApprovalState,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for shell handler".to_string(),
));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
initial_approval_state,
})
.await
}
}
impl ShellCommandHandler {
@@ -178,6 +256,62 @@ impl ShellCommandHandler {
arg0: None,
})
}
async fn handle_with_permission_request_hooks(
&self,
invocation: ToolInvocation,
initial_approval_state: InitialApprovalState,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {}",
self.tool_name().display()
)));
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let workdir = turn.resolve_path(params.workdir.clone());
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),
&params.command,
&workdir,
)
.await;
let prefix_rule = params.prefix_rule.clone();
let exec_params = Self::to_exec_params(
&params,
session.as_ref(),
turn.as_ref(),
session.conversation_id,
turn.tools_config.allow_login_shell,
)?;
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: self.tool_name().display(),
exec_params,
hook_command: params.command,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: true,
shell_runtime_backend: self.shell_runtime_backend(),
initial_approval_state,
})
.await
}
}
impl From<ShellCommandBackendConfig> for ShellCommandHandler {
@@ -219,6 +353,14 @@ impl ToolHandler for ShellHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -228,42 +370,17 @@ impl ToolHandler for ShellHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
Self::handle_with_permission_request_hooks(invocation, InitialApprovalState::Evaluate).await
}
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for shell handler".to_string(),
));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
Self::handle_with_permission_request_hooks(
invocation,
InitialApprovalState::AlreadyApproved,
)
.await
}
}
@@ -297,6 +414,14 @@ impl ToolHandler for ContainerExecHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -306,44 +431,69 @@ impl ToolHandler for ContainerExecHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for container.exec handler".to_string(),
));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "container.exec".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
handle_shell_function_invocation(
invocation,
"container.exec",
InitialApprovalState::Evaluate,
)
.await
}
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
handle_shell_function_invocation(
invocation,
"container.exec",
InitialApprovalState::AlreadyApproved,
)
.await
}
}
async fn handle_shell_function_invocation(
invocation: ToolInvocation,
tool_name: &str,
initial_approval_state: InitialApprovalState,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for {tool_name} handler"
)));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params = ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
initial_approval_state,
})
.await
}
impl ToolHandler for LocalShellHandler {
@@ -376,6 +526,43 @@ impl ToolHandler for LocalShellHandler {
})
}
// Hooks see a joined shell command string, but local_shell stores argv.
// Split on the way back in to invert the hook-facing representation.
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
let command = updated_hook_command(&updated_input)?;
invocation.payload = match invocation.payload {
ToolPayload::Function { arguments } => {
let command = shlex::split(command).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
ToolPayload::Function {
arguments: rewrite_function_arguments(&arguments, "shell", |arguments| {
arguments.insert(
"command".to_string(),
JsonValue::Array(command.into_iter().map(JsonValue::String).collect()),
);
})?,
}
}
ToolPayload::LocalShell { mut params } => {
params.command = shlex::split(command).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
ToolPayload::LocalShell { params }
}
payload => payload,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -393,38 +580,52 @@ impl ToolHandler for LocalShellHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let ToolPayload::LocalShell { params } = payload else {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for local_shell handler".to_string(),
));
};
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "local_shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: None,
prefix_rule: None,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
handle_local_shell_invocation(invocation, InitialApprovalState::Evaluate).await
}
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
handle_local_shell_invocation(invocation, InitialApprovalState::AlreadyApproved).await
}
}
async fn handle_local_shell_invocation(
invocation: ToolInvocation,
initial_approval_state: InitialApprovalState,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let ToolPayload::LocalShell { params } = payload else {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for local_shell handler".to_string(),
));
};
let exec_params = ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "local_shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: None,
prefix_rule: None,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
initial_approval_state,
})
.await
}
fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
@@ -491,6 +692,27 @@ impl ToolHandler for ShellCommandHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported shell_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"shell_command",
"command",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -508,54 +730,16 @@ impl ToolHandler for ShellCommandHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
self.handle_with_permission_request_hooks(invocation, InitialApprovalState::Evaluate)
.await
}
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {}",
self.tool_name().display()
)));
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let workdir = turn.resolve_path(params.workdir.clone());
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),
&params.command,
&workdir,
)
.await;
let prefix_rule = params.prefix_rule.clone();
let exec_params = Self::to_exec_params(
&params,
session.as_ref(),
turn.as_ref(),
session.conversation_id,
turn.tools_config.allow_login_shell,
)?;
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: self.tool_name().display(),
exec_params,
hook_command: params.command,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: true,
shell_runtime_backend: self.shell_runtime_backend(),
})
.await
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
self.handle_with_permission_request_hooks(invocation, InitialApprovalState::AlreadyApproved)
.await
}
}
@@ -573,6 +757,7 @@ impl ShellHandler {
call_id,
freeform,
shell_runtime_backend,
initial_approval_state,
} = args;
let mut exec_params = exec_params;
@@ -735,6 +920,7 @@ impl ShellHandler {
&tool_ctx,
&turn,
turn.approval_policy.value(),
initial_approval_state,
)
.await
.map(|result| result.output);

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use core_test_support::PathBufExt;
use core_test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -16,8 +17,10 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ContainerExecHandler;
use crate::tools::handlers::LocalShellHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -269,6 +272,106 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() {
);
}
#[tokio::test]
async fn shell_handler_rewrites_hook_command_back_to_argv() {
let payload = ToolPayload::Function {
arguments: json!({
"command": ["bash", "-lc", "printf old"],
"workdir": "subdir",
})
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = ShellHandler;
let invocation = handler
.with_updated_hook_input(
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-43".to_string(),
tool_name: codex_tools::ToolName::plain("shell"),
source: ToolCallSource::Direct,
payload,
},
json!({ "command": "bash -lc 'printf new'" }),
)
.expect("shell rewrite should succeed");
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("shell rewrite should preserve a function payload");
};
assert_eq!(
serde_json::from_str::<ShellToolCallParams>(&arguments)
.expect("rewritten shell arguments should parse"),
ShellToolCallParams {
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf new".to_string(),
],
workdir: Some("subdir".to_string()),
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
}
);
}
#[tokio::test]
async fn container_exec_handler_rewrites_hook_command_back_to_argv() {
let payload = ToolPayload::Function {
arguments: json!({
"command": ["bash", "-lc", "printf old"],
"workdir": "subdir",
})
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = ContainerExecHandler;
let invocation = handler
.with_updated_hook_input(
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-44".to_string(),
tool_name: codex_tools::ToolName::plain("container.exec"),
source: ToolCallSource::Direct,
payload,
},
json!({ "command": "bash -lc 'printf new'" }),
)
.expect("container.exec rewrite should succeed");
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("container.exec rewrite should preserve a function payload");
};
assert_eq!(
serde_json::from_str::<ShellToolCallParams>(&arguments)
.expect("rewritten container.exec arguments should parse"),
ShellToolCallParams {
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf new".to_string(),
],
workdir: Some("subdir".to_string()),
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
}
);
}
#[tokio::test]
async fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let payload = ToolPayload::Function {

View File

@@ -14,11 +14,14 @@ use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::sandboxing::InitialApprovalState;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
@@ -162,6 +165,29 @@ impl ToolHandler for ExecCommandHandler {
})
}
// Hooks normalize Bash-like tools to `{ "command": ... }`, while the
// exec_command wire schema still names the field `cmd`.
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported exec_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"exec_command",
"cmd",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -171,205 +197,222 @@ impl ToolHandler for ExecCommandHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
handle_exec_command_invocation(invocation, InitialApprovalState::Evaluate).await
}
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"exec_command handler received unsupported payload".to_string(),
));
}
};
async fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
handle_exec_command_invocation(invocation, InitialApprovalState::AlreadyApproved).await
}
}
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let Some(turn_environment) =
resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?
else {
async fn handle_exec_command_invocation(
invocation: ToolInvocation,
initial_approval_state: InitialApprovalState,
) -> Result<ExecCommandToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
"exec_command handler received unsupported payload".to_string(),
));
};
let cwd = environment_args
.workdir
.as_deref()
.filter(|workdir| !workdir.is_empty())
.map_or_else(
|| turn_environment.cwd.clone(),
|workdir| turn_environment.cwd.join(workdir),
);
let environment = Arc::clone(&turn_environment.environment);
let fs = environment.get_filesystem();
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
&cwd,
)
.await;
let process_id = manager.allocate_process_id().await;
let command = get_command(
&args,
session.user_shell(),
&turn.tools_config.unified_exec_shell_mode,
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let ExecCommandArgs {
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
additional_permissions,
justification,
prefix_rule,
..
} = args;
let max_output_tokens =
effective_max_output_tokens(max_output_tokens, turn.truncation_policy);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
context.session.as_ref(),
cwd.as_path(),
sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
context.turn.approval_policy.value(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = context.turn.approval_policy.value();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
};
let normalized_additional_permissions = match implicit_granted_permissions(
sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let Some(turn_environment) =
resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?
else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
};
let cwd = environment_args
.workdir
.as_deref()
.filter(|workdir| !workdir.is_empty())
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
)
},
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
|| turn_environment.cwd.clone(),
|workdir| turn_environment.cwd.join(workdir),
);
let environment = Arc::clone(&turn_environment.environment);
let fs = environment.get_filesystem();
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
&cwd,
)
.await;
let process_id = manager.allocate_process_id().await;
let command = get_command(
&args,
session.user_shell(),
&turn.tools_config.unified_exec_shell_mode,
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
if let Some(output) = intercept_apply_patch(
&command,
&cwd,
fs.as_ref(),
context.session.clone(),
context.turn.clone(),
Some(&tracker),
&context.call_id,
"exec_command",
let ExecCommandArgs {
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
additional_permissions,
justification,
prefix_rule,
..
} = args;
let max_output_tokens = effective_max_output_tokens(max_output_tokens, turn.truncation_policy);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
context.session.as_ref(),
cwd.as_path(),
sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
context.turn.approval_policy.value(),
codex_protocol::protocol::AskForApproval::OnRequest
)
.await?
{
manager.release_process_id(process_id).await;
return Ok(ExecCommandToolOutput {
event_call_id: String::new(),
chunk_id: String::new(),
wall_time: std::time::Duration::ZERO,
raw_output: output.into_text().into_bytes(),
max_output_tokens: Some(max_output_tokens),
process_id: None,
exit_code: None,
original_token_count: None,
hook_command: None,
});
}
{
let approval_policy = context.turn.approval_policy.value();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens: Some(max_output_tokens),
cwd,
environment,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
},
&context,
let normalized_additional_permissions = match implicit_granted_permissions(
sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
)
.await
{
Ok(response) => Ok(response),
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
Ok(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
max_output_tokens: Some(max_output_tokens),
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
})
}
Err(err) => Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))),
},
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
if let Some(output) = intercept_apply_patch(
&command,
&cwd,
fs.as_ref(),
context.session.clone(),
context.turn.clone(),
Some(&tracker),
&context.call_id,
"exec_command",
)
.await?
{
manager.release_process_id(process_id).await;
return Ok(ExecCommandToolOutput {
event_call_id: String::new(),
chunk_id: String::new(),
wall_time: std::time::Duration::ZERO,
raw_output: output.into_text().into_bytes(),
max_output_tokens: Some(max_output_tokens),
process_id: None,
exit_code: None,
original_token_count: None,
hook_command: None,
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens: Some(max_output_tokens),
cwd,
environment,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
initial_approval_state,
},
&context,
)
.await
{
Ok(response) => Ok(response),
Err(UnifiedExecError::UpdatedInput(updated_input)) => {
Err(FunctionCallError::UpdatedInput(updated_input))
}
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
Ok(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
max_output_tokens: Some(max_output_tokens),
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
})
}
Err(err) => Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))),
}
}

View File

@@ -473,7 +473,29 @@ impl NetworkApprovalService {
.await
{
match permission_request_decision {
PermissionRequestDecision::Allow => {
PermissionRequestDecision::Allow {
updated_input: Some(_),
} => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
NetworkApprovalOutcome::DeniedByPolicy(
"updatedInput is not supported for network approval requests"
.to_string(),
),
)
.await;
}
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
return NetworkDecision::deny(
"updatedInput is not supported for network approval requests",
);
}
PermissionRequestDecision::Allow {
updated_input: None,
} => {
pending
.set_decision(PendingApprovalDecision::AllowOnce)
.await;

View File

@@ -20,6 +20,7 @@ use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::InitialApprovalState;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::ToolCtx;
@@ -130,6 +131,7 @@ impl ToolOrchestrator {
tool_ctx: &ToolCtx,
turn_ctx: &crate::session::turn_context::TurnContext,
approval_policy: AskForApproval,
initial_approval_state: InitialApprovalState,
) -> Result<OrchestratorRunResult<Out>, ToolError>
where
T: ToolRuntime<Rq, Out>,
@@ -141,23 +143,66 @@ impl ToolOrchestrator {
let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review;
// 1) Approval
let mut already_approved = false;
let mut already_approved = matches!(
initial_approval_state,
InitialApprovalState::AlreadyApproved
);
let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy();
let network_sandbox_policy = turn_ctx.network_sandbox_policy();
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
});
match requirement {
ExecApprovalRequirement::Skip { .. } => {
if strict_auto_review {
let guardian_review_id = Some(new_guardian_review_id());
if !already_approved {
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
});
match requirement {
ExecApprovalRequirement::Skip { .. } => {
if strict_auto_review {
let guardian_review_id = Some(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,
network_approval_context: None,
};
let decision = Self::request_approval(
tool,
req,
tool_ctx.call_id.as_str(),
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 } => {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
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: reason,
network_approval_context: None,
};
let decision = Self::request_approval(
@@ -166,50 +211,16 @@ impl ToolOrchestrator {
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ false,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&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 } => {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
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: reason,
network_approval_context: None,
};
let decision = Self::request_approval(
tool,
req,
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
/*evaluate_permission_request_hooks*/ !strict_auto_review,
&otel,
)
.await?;
Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision)
.await?;
already_approved = true;
}
}
// 2) First attempt under the selected sandbox.
@@ -406,7 +417,14 @@ impl ToolOrchestrator {
)
.await
{
Some(PermissionRequestDecision::Allow) => {
Some(PermissionRequestDecision::Allow {
updated_input: Some(updated_input),
}) => {
return Err(ToolError::UpdatedInput(updated_input));
}
Some(PermissionRequestDecision::Allow {
updated_input: None,
}) => {
let decision = ReviewDecision::Approved;
otel.tool_decision(
&tool_ctx.tool_name,

View File

@@ -81,6 +81,20 @@ pub trait ToolHandler: Send + Sync {
None
}
/// Rebuilds a tool invocation from hook-facing `tool_input`.
///
/// Tools that opt into input-rewriting hooks should invert the same stable
/// hook contract they expose from `pre_tool_use_payload`.
fn with_updated_hook_input(
&self,
_invocation: ToolInvocation,
_updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
Err(FunctionCallError::RespondToModel(
"tool does not support hook input rewriting".to_string(),
))
}
/// Creates an optional consumer for streamed tool argument diffs.
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
None
@@ -92,6 +106,18 @@ pub trait ToolHandler: Send + Sync {
&self,
invocation: ToolInvocation,
) -> impl std::future::Future<Output = Result<Self::Output, FunctionCallError>> + Send;
/// Continue execution after a `PermissionRequest` hook rewrites input.
///
/// Implementations that route through approval-time hooks should override
/// this to reuse the hook verdict instead of evaluating `PermissionRequest`
/// again for the rewritten payload.
fn handle_after_permission_request_rewrite(
&self,
invocation: ToolInvocation,
) -> impl std::future::Future<Output = Result<Self::Output, FunctionCallError>> + Send {
self.handle(invocation)
}
}
/// Consumes streamed argument diffs for a tool call and emits protocol events
@@ -169,6 +195,12 @@ trait AnyToolHandler: Send + Sync {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload>;
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError>;
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>>;
fn handle_any<'a>(
&'a self,
@@ -192,6 +224,14 @@ where
ToolHandler::pre_tool_use_payload(self, invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
ToolHandler::with_updated_hook_input(self, invocation, updated_input)
}
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
ToolHandler::create_diff_consumer(self)
}
@@ -200,9 +240,18 @@ where
invocation: ToolInvocation,
) -> BoxFuture<'a, Result<AnyToolResult, FunctionCallError>> {
Box::pin(async move {
let mut invocation = invocation;
let output = match self.handle(invocation.clone()).await {
Err(FunctionCallError::UpdatedInput(updated_input)) => {
invocation =
ToolHandler::with_updated_hook_input(self, invocation, updated_input)?;
ToolHandler::handle_after_permission_request_rewrite(self, invocation.clone())
.await?
}
result => result?,
};
let call_id = invocation.call_id.clone();
let payload = invocation.payload.clone();
let output = self.handle(invocation.clone()).await?;
let post_tool_use_payload =
ToolHandler::post_tool_use_payload(self, &invocation, &output);
Ok(AnyToolResult {
@@ -260,14 +309,12 @@ impl ToolRegistry {
)]
pub(crate) async fn dispatch_any(
&self,
invocation: ToolInvocation,
mut invocation: ToolInvocation,
) -> Result<AnyToolResult, FunctionCallError> {
let tool_name = invocation.tool_name.clone();
let display_name = tool_name.display();
let call_id_owned = invocation.call_id.clone();
let otel = invocation.turn.session_telemetry.clone();
let payload_for_response = invocation.payload.clone();
let log_payload = payload_for_response.log_payload();
let metric_tags = [
(
"sandbox",
@@ -315,6 +362,7 @@ impl ToolRegistry {
Some(handler) => handler,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
&display_name,
&call_id_owned,
@@ -334,6 +382,7 @@ impl ToolRegistry {
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {display_name} invoked with incompatible payload");
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
&display_name,
&call_id_owned,
@@ -350,8 +399,8 @@ impl ToolRegistry {
return Err(err);
}
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
&& let Some(message) = run_pre_tool_use_hooks(
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) {
match run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
@@ -359,10 +408,21 @@ impl ToolRegistry {
&pre_tool_use_payload.tool_input,
)
.await
{
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
{
crate::hook_runtime::PreToolUseHookResult::Blocked(message) => {
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
crate::hook_runtime::PreToolUseHookResult::Continue {
updated_input: Some(updated_input),
} => {
invocation = handler.with_updated_hook_input(invocation, updated_input)?;
}
crate::hook_runtime::PreToolUseHookResult::Continue {
updated_input: None,
} => {}
}
}
let is_mutating = handler.is_mutating(&invocation).await;
@@ -370,6 +430,7 @@ impl ToolRegistry {
let invocation_for_tool = invocation.clone();
let started = Instant::now();
let log_payload = invocation.payload.log_payload();
let result = otel
.log_tool_result_with_tags(
&display_name,

View File

@@ -420,7 +420,21 @@ impl CoreShellActionProvider {
)
.await
{
Some(PermissionRequestDecision::Allow) => {
Some(PermissionRequestDecision::Allow {
updated_input: Some(_),
}) => {
return PromptDecision {
decision: ReviewDecision::Denied,
guardian_review_id: None,
rejection_message: Some(
"updatedInput is not supported for intercepted exec approvals"
.to_string(),
),
};
}
Some(PermissionRequestDecision::Allow {
updated_input: None,
}) => {
return PromptDecision {
decision: ReviewDecision::Approved,
guardian_review_id: None,

View File

@@ -347,9 +347,16 @@ pub(crate) struct ToolCtx {
pub tool_name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InitialApprovalState {
Evaluate,
AlreadyApproved,
}
#[derive(Debug)]
pub(crate) enum ToolError {
Rejected(String),
UpdatedInput(serde_json::Value),
Codex(CodexErr),
}

View File

@@ -1,4 +1,5 @@
use codex_protocol::exec_output::ExecToolCallOutput;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
@@ -23,6 +24,8 @@ pub(crate) enum UnifiedExecError {
message: String,
output: ExecToolCallOutput,
},
#[error("tool input rewritten by hook")]
UpdatedInput(Value),
}
impl UnifiedExecError {

View File

@@ -39,6 +39,7 @@ use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::sandboxing::InitialApprovalState;
mod async_watcher;
mod errors;
@@ -103,6 +104,7 @@ pub(crate) struct ExecCommandRequest {
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub prefix_rule: Option<Vec<String>>,
pub initial_approval_state: InitialApprovalState,
}
#[derive(Debug)]

View File

@@ -221,6 +221,9 @@ async fn finish_deferred_network_approval_for_session(
fn network_approval_error_message(err: ToolError) -> String {
match err {
ToolError::Rejected(message) => message,
ToolError::UpdatedInput(_) => {
"updatedInput is not supported for deferred network approvals".to_string()
}
ToolError::Codex(err) => err.to_string(),
}
}
@@ -1049,6 +1052,7 @@ impl UnifiedExecProcessManager {
&tool_ctx,
&context.turn,
context.turn.approval_policy.value(),
request.initial_approval_state,
)
.await
.map(|result| (result.output, result.deferred_network_approval))
@@ -1063,6 +1067,9 @@ impl UnifiedExecProcessManager {
};
UnifiedExecError::sandbox_denied(message, output)
}
ToolError::UpdatedInput(updated_input) => {
UnifiedExecError::UpdatedInput(updated_input)
}
other => UnifiedExecError::create_process(format!("{other:?}")),
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::tools::sandboxing::InitialApprovalState;
use pretty_assertions::assert_eq;
use tokio::time::Duration;
use tokio::time::Instant;
@@ -187,6 +188,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
additional_permissions_preapproved: false,
justification: None,
prefix_rule: None,
initial_approval_state: InitialApprovalState::Evaluate,
};
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));

View File

@@ -307,6 +307,54 @@ elif mode == "exit_2":
Ok(())
}
fn write_updating_pre_tool_use_hook(
home: &Path,
matcher: &str,
updated_input: &Value,
) -> Result<()> {
let script_path = home.join("pre_tool_use_hook.py");
let log_path = home.join("pre_tool_use_hook_log.jsonl");
let updated_input_json =
serde_json::to_string(updated_input).context("serialize updated pre tool input")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {updated_input_json}
}}
}}))
"#,
log_path = log_path.display(),
updated_input_json = updated_input_json,
);
let hooks = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "rewriting pre tool input",
}]
}]
}
});
fs::write(&script_path, script).context("write updating pre tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_pre_tool_use_hook_toml(
home: &Path,
script_name: &str,
@@ -448,6 +496,56 @@ elif mode == "exit_2":
Ok(())
}
fn write_updating_permission_request_hook(
home: &Path,
matcher: &str,
updated_input: &Value,
) -> Result<()> {
let script_path = home.join("permission_request_hook.py");
let log_path = home.join("permission_request_hook_log.jsonl");
let updated_input_json =
serde_json::to_string(updated_input).context("serialize updated permission input")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "allow",
"updatedInput": {updated_input_json}
}}
}}
}}))
"#,
log_path = log_path.display(),
updated_input_json = updated_input_json,
);
let hooks = serde_json::json!({
"hooks": {
"PermissionRequest": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "rewriting permission request input",
}]
}]
}
});
fs::write(&script_path, script).context("write updating permission hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn install_allow_permission_request_hook(home: &Path) -> Result<()> {
write_permission_request_hook(
home,
@@ -1505,6 +1603,92 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
Ok(())
}
#[tokio::test]
async fn permission_request_hook_rewrites_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-shell-command-rewrite";
let original_marker = std::env::temp_dir().join("permissionrequest-shell-command-original");
let rewritten_marker = std::env::temp_dir().join("permissionrequest-shell-command-rewritten");
let original_command = format!("rm -f {}", original_marker.display());
let rewritten_command = format!("printf rewritten > {}", rewritten_marker.display());
let args = serde_json::json!({ "command": original_command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "permission hook rewrote it"),
ev_completed("resp-2"),
]),
],
)
.await;
let updated_input = serde_json::json!({ "command": rewritten_command.clone() });
let mut builder = test_codex()
.with_pre_build_hook(move |home| {
if let Err(error) =
write_updating_permission_request_hook(home, "^Bash$", &updated_input)
{
panic!("failed to write updating permission hook fixture: {error}");
}
})
.with_config(|config| {
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
if original_marker.exists() {
fs::remove_file(&original_marker).context("remove stale original permission marker")?;
}
if rewritten_marker.exists() {
fs::remove_file(&rewritten_marker).context("remove stale rewritten permission marker")?;
}
fs::write(&original_marker, "seed").context("create original permission marker")?;
test.submit_turn_with_approval_and_permission_profile(
"run the rewritten shell command after hook approval",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
requests[1].function_call_output(call_id);
assert!(
original_marker.exists(),
"original command should not execute after permission rewrite"
);
assert_eq!(
fs::read_to_string(&rewritten_marker).context("read rewritten permission marker")?,
"rewritten"
);
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_permission_request_hook_input(
&hook_inputs[0],
"Bash",
&original_command,
/*description*/ None,
);
assert!(hook_inputs[0].get("tool_use_id").is_none());
Ok(())
}
#[tokio::test]
async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1579,6 +1763,92 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result
Ok(())
}
#[tokio::test]
async fn permission_request_hook_rewrites_apply_patch_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-apply-patch-rewrite";
let original_file = "permission_request_apply_patch_original.txt";
let rewritten_file = "permission_request_apply_patch_rewritten.txt";
let original_path = format!("../{original_file}");
let rewritten_path = format!("../{rewritten_file}");
let original_patch = format!(
r#"*** Begin Patch
*** Add File: {original_path}
+original
*** End Patch"#
);
let rewritten_patch = format!(
r#"*** Begin Patch
*** Add File: {rewritten_path}
+rewritten
*** End Patch"#
);
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_apply_patch_function_call(call_id, &original_patch),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "permission hook rewrote apply_patch"),
ev_completed("resp-2"),
]),
],
)
.await;
let updated_input = serde_json::json!({ "command": rewritten_patch.clone() });
let mut builder = test_codex()
.with_pre_build_hook(move |home| {
if let Err(error) =
write_updating_permission_request_hook(home, "^apply_patch$", &updated_input)
{
panic!("failed to write updating permission hook fixture: {error}");
}
})
.with_config(|config| {
config.include_apply_patch_tool = true;
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
test.submit_turn_with_approval_and_permission_profile(
"apply the rewritten patch after hook approval",
AskForApproval::OnRequest,
restrictive_workspace_write_profile(),
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
requests[1].function_call_output(call_id);
assert!(
!test.workspace_path(&original_path).exists(),
"original patch should not create its target file"
);
assert_eq!(
fs::read_to_string(test.workspace_path(&rewritten_path))
.context("read rewritten permission apply_patch file")?,
"rewritten\n"
);
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_permission_request_hook_input(
&hook_inputs[0],
"apply_patch",
&original_patch,
/*description*/ None,
);
Ok(())
}
#[tokio::test]
async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2081,6 +2351,81 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() ->
!marker.exists(),
"blocked command should not create marker file"
);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-rewrite";
let original_marker = std::env::temp_dir().join("pretooluse-shell-command-original-marker");
let rewritten_marker = std::env::temp_dir().join("pretooluse-shell-command-rewritten-marker");
let original_command = format!("printf original > {}", original_marker.display());
let rewritten_command = format!("printf rewritten > {}", rewritten_marker.display());
let args = serde_json::json!({ "command": original_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", "hook rewrote it"),
ev_completed("resp-2"),
]),
],
)
.await;
let updated_input = serde_json::json!({ "command": rewritten_command });
let mut builder = test_codex()
.with_pre_build_hook(move |home| {
if let Err(error) = write_updating_pre_tool_use_hook(home, "^Bash$", &updated_input) {
panic!("failed to write updating pre tool use hook fixture: {error}");
}
})
.with_config(|config| {
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
if original_marker.exists() {
fs::remove_file(&original_marker).context("remove stale original pre tool marker")?;
}
if rewritten_marker.exists() {
fs::remove_file(&rewritten_marker).context("remove stale rewritten pre tool marker")?;
}
test.submit_turn_with_permission_profile(
"run the rewritten shell command",
PermissionProfile::Disabled,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
requests[1].function_call_output(call_id);
assert!(
!original_marker.exists(),
"original shell command should not execute after rewrite"
);
assert_eq!(
fs::read_to_string(&rewritten_marker).context("read rewritten pre tool marker")?,
"rewritten"
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["tool_input"]["command"], original_command);
Ok(())
}
@@ -2675,6 +3020,80 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn pre_tool_use_rewrites_apply_patch_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-apply-patch-rewrite";
let original_file = "pre_tool_use_apply_patch_original.txt";
let rewritten_file = "pre_tool_use_apply_patch_rewritten.txt";
let original_patch = format!(
r#"*** Begin Patch
*** Add File: {original_file}
+original
*** End Patch"#
);
let rewritten_patch = format!(
r#"*** Begin Patch
*** Add File: {rewritten_file}
+rewritten
*** End Patch"#
);
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_apply_patch_function_call(call_id, &original_patch),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "apply_patch rewritten"),
ev_completed("resp-2"),
]),
],
)
.await;
let updated_input = serde_json::json!({ "command": rewritten_patch });
let mut builder = test_codex()
.with_pre_build_hook(move |home| {
if let Err(error) =
write_updating_pre_tool_use_hook(home, "^apply_patch$", &updated_input)
{
panic!("failed to write updating pre tool use hook fixture: {error}");
}
})
.with_config(|config| {
config.include_apply_patch_tool = true;
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
test.submit_turn("apply the rewritten patch").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
requests[1].function_call_output(call_id);
assert!(
!test.workspace_path(original_file).exists(),
"original patch should not create its target file"
);
assert_eq!(
fs::read_to_string(test.workspace_path(rewritten_file))
.context("read rewritten apply_patch file")?,
"rewritten\n"
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["tool_input"]["command"], original_patch);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -74,6 +74,50 @@ print(json.dumps({{
Ok(())
}
fn write_updating_pre_tool_use_hook(home: &Path, updated_message: &str) -> Result<()> {
let script_path = home.join("pre_tool_use_hook.py");
let log_path = home.join("pre_tool_use_hook_log.jsonl");
let updated_message_json =
serde_json::to_string(updated_message).context("serialize updated MCP message")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {{ "message": {updated_message_json} }}
}}
}}))
"#,
log_path = log_path.display(),
updated_message_json = updated_message_json,
);
let hooks = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": RMCP_HOOK_MATCHER,
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "rewriting MCP pre tool input",
}]
}]
}
});
fs::write(&script_path, script).context("write updating pre tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_post_tool_use_hook(home: &Path, additional_context: &str) -> Result<()> {
let script_path = home.join("post_tool_use_hook.py");
let log_path = home.join("post_tool_use_hook_log.jsonl");
@@ -249,6 +293,76 @@ async fn pre_tool_use_blocks_mcp_tool_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_rewrites_mcp_tool_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-rmcp-echo-rewrite";
let rewritten_message = "rewritten mcp hook input";
let arguments = json!({ "message": RMCP_ECHO_MESSAGE }).to_string();
let call_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(call_id, RMCP_NAMESPACE, "echo", &arguments),
ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "mcp pre hook rewrote it"),
ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = stdio_server_bin()?;
let test = test_codex()
.with_pre_build_hook(move |home| {
if let Err(error) = write_updating_pre_tool_use_hook(home, rewritten_message) {
panic!("failed to write MCP updating pre tool use hook fixture: {error}");
}
})
.with_config(move |config| {
enable_hooks_and_rmcp_server(config, rmcp_test_server_bin, AppToolApproval::Approve);
})
.build(&server)
.await?;
test.submit_turn("call the rmcp echo tool with the MCP pre hook rewrite")
.await?;
let final_request = final_mock.single_request();
let output_item = final_request.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("MCP tool output string");
assert!(
output.contains(&format!("ECHOING: {rewritten_message}")),
"MCP tool should execute the rewritten input",
);
assert!(
!output.contains(RMCP_ECHO_MESSAGE),
"MCP tool should not execute the original input",
);
let hook_inputs = read_hook_inputs(test.codex_home_path(), "pre_tool_use_hook_log.jsonl")?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_input"],
json!({ "message": RMCP_ECHO_MESSAGE }),
);
call_mock.single_request();
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_records_mcp_tool_payload_and_context() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -37,7 +37,7 @@
},
"updatedInput": {
"default": null,
"description": "Reserved for a future input-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present."
"description": "Replaces the entire tool input object when `behavior` is `allow`."
},
"updatedPermissions": {
"default": null,

View File

@@ -17,13 +17,18 @@ pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub additional_context: Option<String>,
pub updated_input: Option<serde_json::Value>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PermissionRequestDecision {
Allow,
Deny { message: String },
Allow {
updated_input: Option<serde_json::Value>,
},
Deny {
message: String,
},
}
#[derive(Debug, Clone)]
@@ -125,11 +130,24 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
} else {
None
};
let updated_input = if invalid_reason.is_none() {
hook_specific_output.and_then(|output| {
matches!(
output.permission_decision,
Some(PreToolUsePermissionDecisionWire::Allow)
)
.then(|| output.updated_input.clone())
.flatten()
})
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
additional_context,
updated_input,
invalid_reason,
})
}
@@ -301,7 +319,9 @@ fn unsupported_permission_request_hook_specific_output(
decision: Option<&PermissionRequestDecisionWire>,
) -> Option<String> {
let decision = decision?;
if decision.updated_input.is_some() {
if decision.updated_input.is_some()
&& !matches!(decision.behavior, PermissionRequestBehaviorWire::Allow)
{
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
} else if decision.updated_permissions.is_some() {
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
@@ -316,7 +336,9 @@ fn permission_request_decision(
decision: &PermissionRequestDecisionWire,
) -> PermissionRequestDecision {
match decision.behavior {
PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow,
PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow {
updated_input: decision.updated_input.clone(),
},
PermissionRequestBehaviorWire::Deny => PermissionRequestDecision::Deny {
message: decision
.message
@@ -340,13 +362,16 @@ fn unsupported_post_tool_use_hook_specific_output(
fn unsupported_pre_tool_use_hook_specific_output(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_input.is_some() {
Some("PreToolUse hook returned unsupported updatedInput".to_string())
if output.updated_input.is_some()
&& !matches!(
output.permission_decision,
Some(PreToolUsePermissionDecisionWire::Allow)
)
{
Some("PreToolUse hook returned updatedInput without permissionDecision:allow".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
}
Some(PreToolUsePermissionDecisionWire::Allow) => None,
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
}
@@ -420,7 +445,7 @@ mod tests {
use super::parse_permission_request;
#[test]
fn permission_request_rejects_reserved_updated_input_field() {
fn permission_request_accepts_allow_updated_input_field() {
let parsed = parse_permission_request(
&json!({
"continue": true,
@@ -436,6 +461,31 @@ mod tests {
)
.expect("permission request hook output should parse");
assert_eq!(
parsed.decision,
Some(super::PermissionRequestDecision::Allow {
updated_input: Some(json!({})),
})
);
}
#[test]
fn permission_request_rejects_deny_updated_input_field() {
let parsed = parse_permission_request(
&json!({
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"updatedInput": {}
}
}
})
.to_string(),
)
.expect("permission request hook output should parse");
assert_eq!(
parsed.invalid_reason,
Some("PermissionRequest hook returned unsupported updatedInput".to_string())

View File

@@ -1,9 +1,9 @@
//! Permission-request hook execution.
//!
//! This event runs in the approval path, before guardian or user approval UI is
//! shown. Unlike `pre_tool_use`, handlers do not rewrite tool input or block by
//! stopping execution outright; instead they can return a concrete allow/deny
//! decision, or decline to decide and let the normal approval flow continue.
//! shown. Handlers can return a concrete allow/deny decision, optionally
//! replacing the full tool input object on allow, or decline to decide and let
//! the normal approval flow continue.
//!
//! The event also mirrors the rest of the hook system's lifecycle:
//!
@@ -47,7 +47,7 @@ pub struct PermissionRequestRequest {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionRequestDecision {
Allow,
Allow { updated_input: Option<Value> },
Deny { message: String },
}
@@ -154,8 +154,10 @@ fn resolve_permission_request_decision<'a>(
let mut resolved_allow = None;
for decision in decisions {
match decision {
PermissionRequestDecision::Allow => {
resolved_allow = Some(PermissionRequestDecision::Allow);
PermissionRequestDecision::Allow { updated_input } => {
resolved_allow = Some(PermissionRequestDecision::Allow {
updated_input: updated_input.clone(),
});
}
PermissionRequestDecision::Deny { message } => {
return Some(PermissionRequestDecision::Deny {
@@ -219,8 +221,8 @@ fn parse_completed(
});
} else if let Some(parsed_decision) = parsed.decision {
match parsed_decision {
output_parser::PermissionRequestDecision::Allow => {
decision = Some(PermissionRequestDecision::Allow);
output_parser::PermissionRequestDecision::Allow { updated_input } => {
decision = Some(PermissionRequestDecision::Allow { updated_input });
}
output_parser::PermissionRequestDecision::Deny { message } => {
status = HookRunStatus::Blocked;
@@ -294,7 +296,9 @@ mod tests {
#[test]
fn permission_request_deny_overrides_earlier_allow() {
let decisions = [
PermissionRequestDecision::Allow,
PermissionRequestDecision::Allow {
updated_input: None,
},
PermissionRequestDecision::Deny {
message: "repo deny".to_string(),
},
@@ -311,13 +315,38 @@ mod tests {
#[test]
fn permission_request_returns_allow_when_no_handler_denies() {
let decisions = [
PermissionRequestDecision::Allow,
PermissionRequestDecision::Allow,
PermissionRequestDecision::Allow {
updated_input: None,
},
PermissionRequestDecision::Allow {
updated_input: None,
},
];
assert_eq!(
resolve_permission_request_decision(decisions.iter()),
Some(PermissionRequestDecision::Allow)
Some(PermissionRequestDecision::Allow {
updated_input: None,
})
);
}
#[test]
fn permission_request_keeps_highest_precedence_allow_update() {
let decisions = [
PermissionRequestDecision::Allow {
updated_input: Some(serde_json::json!({ "command": "first" })),
},
PermissionRequestDecision::Allow {
updated_input: Some(serde_json::json!({ "command": "second" })),
},
];
assert_eq!(
resolve_permission_request_decision(decisions.iter()),
Some(PermissionRequestDecision::Allow {
updated_input: Some(serde_json::json!({ "command": "second" })),
})
);
}

View File

@@ -38,6 +38,7 @@ pub struct PreToolUseOutcome {
pub should_block: bool,
pub block_reason: Option<String>,
pub additional_contexts: Vec<String>,
pub updated_input: Option<Value>,
}
#[derive(Debug, Default, PartialEq, Eq)]
@@ -45,6 +46,7 @@ struct PreToolUseHandlerData {
should_block: bool,
block_reason: Option<String>,
additional_contexts_for_model: Vec<String>,
updated_input: Option<Value>,
}
pub(crate) fn preview(
@@ -81,6 +83,7 @@ pub(crate) async fn run(
should_block: false,
block_reason: None,
additional_contexts: Vec::new(),
updated_input: None,
};
}
@@ -116,6 +119,14 @@ pub(crate) async fn run(
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
let updated_input = if should_block {
None
} else {
results
.iter()
.rev()
.find_map(|result| result.data.updated_input.clone())
};
PreToolUseOutcome {
hook_events: results
@@ -127,6 +138,7 @@ pub(crate) async fn run(
should_block,
block_reason,
additional_contexts,
updated_input,
}
}
@@ -161,6 +173,7 @@ fn parse_completed(
let mut should_block = false;
let mut block_reason = None;
let mut additional_contexts_for_model = Vec::new();
let mut updated_input = None;
match run_result.error.as_deref() {
Some(error) => {
@@ -204,6 +217,9 @@ fn parse_completed(
text: reason,
});
}
if !should_block {
updated_input = parsed.updated_input;
}
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
@@ -258,6 +274,7 @@ fn parse_completed(
should_block,
block_reason,
additional_contexts_for_model,
updated_input,
},
}
}
@@ -268,6 +285,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToo
should_block: false,
block_reason: None,
additional_contexts: Vec::new(),
updated_input: None,
}
}
@@ -320,6 +338,7 @@ mod tests {
should_block: true,
block_reason: Some("do not run that".to_string()),
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -332,6 +351,31 @@ mod tests {
);
}
#[test]
fn permission_decision_allow_can_update_input() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo rewritten"}}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
additional_contexts_for_model: Vec::new(),
updated_input: Some(serde_json::json!({ "command": "echo rewritten" })),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(parsed.completed.run.entries, vec![]);
}
#[test]
fn deprecated_block_decision_blocks_processing() {
let parsed = parse_completed(
@@ -350,6 +394,7 @@ mod tests {
should_block: true,
block_reason: Some("do not run that".to_string()),
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -380,6 +425,7 @@ mod tests {
should_block: true,
block_reason: Some("do not run that".to_string()),
additional_contexts_for_model: vec!["remember this".to_string()],
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -416,6 +462,7 @@ mod tests {
should_block: false,
block_reason: None,
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -442,6 +489,7 @@ mod tests {
should_block: false,
block_reason: None,
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -472,6 +520,7 @@ mod tests {
should_block: true,
block_reason: Some("do not run that".to_string()),
additional_contexts_for_model: vec!["nope".to_string()],
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
@@ -504,6 +553,7 @@ mod tests {
should_block: false,
block_reason: None,
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
@@ -524,6 +574,7 @@ mod tests {
should_block: false,
block_reason: None,
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
@@ -550,6 +601,7 @@ mod tests {
should_block: true,
block_reason: Some("blocked by policy".to_string()),
additional_contexts_for_model: Vec::new(),
updated_input: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);

View File

@@ -138,9 +138,7 @@ pub(crate) struct PermissionRequestHookSpecificOutputWire {
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestDecisionWire {
pub behavior: PermissionRequestBehaviorWire,
/// Reserved for a future input-rewrite capability.
///
/// PermissionRequest hooks currently fail closed if this field is present.
/// Replaces the entire tool input object when `behavior` is `allow`.
#[serde(default)]
pub updated_input: Option<Value>,
/// Reserved for a future permission-rewrite capability.