mirror of
https://github.com/openai/codex.git
synced 2026-05-11 23:02:39 +00:00
Compare commits
22 Commits
dev/mzeng/
...
abhinav/ho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb3b6fadef | ||
|
|
412d7d5b26 | ||
|
|
786882a0bc | ||
|
|
04ae865e76 | ||
|
|
108ecb0a89 | ||
|
|
4af5d3e146 | ||
|
|
bbfa80cda9 | ||
|
|
fa1b31a7b2 | ||
|
|
afa8cb9c4c | ||
|
|
0368d82457 | ||
|
|
f8dbb08834 | ||
|
|
70cdcb27a1 | ||
|
|
c92387f864 | ||
|
|
2f423b129d | ||
|
|
a4176794dc | ||
|
|
26651a0e62 | ||
|
|
f2888eda0f | ||
|
|
607d2544f9 | ||
|
|
b1ea7125f6 | ||
|
|
7932199328 | ||
|
|
bcccc6c208 | ||
|
|
61724ae083 |
@@ -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}")]
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms, 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(¶ms.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(),
|
||||
¶ms.command,
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params = Self::to_exec_params(
|
||||
¶ms,
|
||||
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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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(),
|
||||
¶ms.command,
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params = Self::to_exec_params(
|
||||
¶ms,
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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" })),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user