Compare commits

...

13 Commits

Author SHA1 Message Date
Abhinav Vedmala
a891c4d325 type approval request families 2026-05-02 12:51:03 -07:00
Abhinav Vedmala
dbff525377 document mcp approval prompt helpers 2026-05-02 11:22:28 -07:00
Abhinav Vedmala
c30a4d2756 restore elicitation meta helper name 2026-05-02 11:21:15 -07:00
Abhinav Vedmala
11655ee6b3 restore mcp approval constant ordering 2026-05-02 11:18:55 -07:00
Abhinav Vedmala
27c2bdc3e7 restore mcp approval helper ordering 2026-05-02 11:08:34 -07:00
Abhinav Vedmala
7113173442 reduce mcp approval prompt diff churn 2026-05-02 11:03:55 -07:00
Abhinav
49d55626f6 Merge branch 'main' into codex/centralize-approval-prompts 2026-05-02 10:32:34 -07:00
Abhinav Vedmala
280698fcf6 fix MCP fallback prompt quotes 2026-05-02 10:21:50 -07:00
Abhinav Vedmala
a1c0d3e5b3 trim MCP approval prompt helpers 2026-05-02 00:43:12 -07:00
Abhinav Vedmala
f06870f376 move MCP approval prompts back to MCP 2026-05-02 00:31:09 -07:00
Abhinav Vedmala
ad605dc791 restore GuardianApprovalRequest name 2026-05-02 00:29:19 -07:00
Abhinav Vedmala
fd6644fd82 Fix approval prompt lint callsites 2026-05-01 23:57:56 -07:00
Abhinav Vedmala
15bb7f5530 centralize approval prompt shaping 2026-05-01 21:16:10 -07:00
21 changed files with 1945 additions and 1140 deletions

View File

@@ -0,0 +1,976 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use codex_analytics::GuardianReviewedAction;
use codex_protocol::approvals::ExecApprovalRequestEvent;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianCommandSource;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Serialize;
use serde_json::Value;
use crate::guardian::GUARDIAN_MAX_ACTION_STRING_TOKENS;
use crate::guardian::guardian_truncate_text;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
/// Canonical description of an approval-worthy action in core.
///
/// This type should describe the action being reviewed exactly once, with
/// guardian review, approval hooks, and user-prompt transports deriving their
/// own projections from it.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ApprovalRequest {
Command(CommandApprovalRequest),
ApplyPatch(ApplyPatchApprovalRequest),
McpToolCall(McpToolCallApprovalRequest),
RequestPermissions(RequestPermissionsApprovalRequest),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CommandApprovalRequest {
Shell {
id: String,
command: Vec<String>,
hook_command: String,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
},
ExecCommand {
id: String,
command: Vec<String>,
hook_command: String,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
tty: bool,
},
#[cfg(unix)]
Execve {
id: String,
source: GuardianCommandSource,
program: String,
argv: Vec<String>,
cwd: AbsolutePathBuf,
additional_permissions: Option<AdditionalPermissionProfile>,
},
NetworkAccess {
id: String,
turn_id: String,
target: String,
hook_command: String,
cwd: AbsolutePathBuf,
host: String,
protocol: NetworkApprovalProtocol,
port: u16,
trigger: Option<GuardianNetworkAccessTrigger>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ApplyPatchApprovalRequest {
pub(crate) id: String,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) files: Vec<AbsolutePathBuf>,
pub(crate) changes: HashMap<PathBuf, FileChange>,
pub(crate) patch: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct McpToolCallApprovalRequest {
pub(crate) id: String,
pub(crate) server: String,
pub(crate) tool_name: String,
pub(crate) hook_tool_name: String,
pub(crate) arguments: Option<Value>,
pub(crate) connector_id: Option<String>,
pub(crate) connector_name: Option<String>,
pub(crate) connector_description: Option<String>,
pub(crate) tool_title: Option<String>,
pub(crate) tool_description: Option<String>,
pub(crate) annotations: Option<GuardianMcpAnnotations>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RequestPermissionsApprovalRequest {
pub(crate) id: String,
pub(crate) turn_id: String,
pub(crate) reason: Option<String>,
pub(crate) permissions: RequestPermissionProfile,
pub(crate) cwd: AbsolutePathBuf,
}
impl ApprovalRequest {
pub(crate) fn permission_request_payload(&self) -> Option<PermissionRequestPayload> {
match self {
Self::Command(request) => Some(request.permission_request_payload()),
Self::ApplyPatch(request) => Some(request.permission_request_payload()),
Self::McpToolCall(request) => Some(request.permission_request_payload()),
Self::RequestPermissions(_) => None,
}
}
}
impl CommandApprovalRequest {
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
match self {
Self::Shell {
hook_command,
justification,
..
}
| Self::ExecCommand {
hook_command,
justification,
..
} => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()),
#[cfg(unix)]
Self::Execve { program, argv, .. } => {
let mut command = vec![program.clone()];
if argv.len() > 1 {
command.extend_from_slice(&argv[1..]);
}
PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(&command),
/*description*/ None,
)
}
Self::NetworkAccess {
target,
hook_command,
..
} => PermissionRequestPayload::bash(
hook_command.clone(),
Some(format!("network-access {target}")),
),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn exec_approval_event(
&self,
turn_id: String,
approval_id: Option<String>,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
available_decisions: Option<Vec<ReviewDecision>>,
) -> ExecApprovalRequestEvent {
match self {
Self::Shell {
id,
command,
cwd,
additional_permissions,
..
}
| Self::ExecCommand {
id,
command,
cwd,
additional_permissions,
..
} => ExecApprovalRequestEvent {
call_id: id.clone(),
approval_id,
turn_id,
command: command.clone(),
cwd: cwd.clone(),
reason,
network_approval_context,
proposed_execpolicy_amendment,
proposed_network_policy_amendments,
additional_permissions: additional_permissions.clone(),
available_decisions,
parsed_cmd: codex_shell_command::parse_command::parse_command(command),
},
#[cfg(unix)]
Self::Execve {
id,
argv,
cwd,
additional_permissions,
..
} => ExecApprovalRequestEvent {
call_id: id.clone(),
approval_id,
turn_id,
command: argv.clone(),
cwd: cwd.clone(),
reason,
network_approval_context,
proposed_execpolicy_amendment,
proposed_network_policy_amendments,
additional_permissions: additional_permissions.clone(),
available_decisions,
parsed_cmd: codex_shell_command::parse_command::parse_command(argv),
},
Self::NetworkAccess {
id,
turn_id,
target,
cwd,
host,
protocol,
..
} => {
let command = vec!["network-access".to_string(), target.clone()];
let network_approval_context = Some(NetworkApprovalContext {
host: host.clone(),
protocol: *protocol,
});
let proposed_network_policy_amendments = proposed_network_policy_amendments
.or_else(|| {
Some(vec![
NetworkPolicyAmendment {
host: host.clone(),
action: codex_protocol::approvals::NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: host.clone(),
action: codex_protocol::approvals::NetworkPolicyRuleAction::Deny,
},
])
});
ExecApprovalRequestEvent {
call_id: id.clone(),
approval_id,
turn_id: turn_id.clone(),
command: command.clone(),
cwd: cwd.clone(),
reason,
network_approval_context,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments,
additional_permissions: None,
available_decisions,
parsed_cmd: codex_shell_command::parse_command::parse_command(&command),
}
}
}
}
}
impl ApplyPatchApprovalRequest {
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
PermissionRequestPayload {
tool_name: HookToolName::apply_patch(),
tool_input: serde_json::json!({ "command": self.patch }),
}
}
pub(crate) fn apply_patch_approval_event(
&self,
turn_id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> ApplyPatchApprovalRequestEvent {
ApplyPatchApprovalRequestEvent {
call_id: self.id.clone(),
turn_id,
changes: self.changes.clone(),
reason,
grant_root,
}
}
}
impl McpToolCallApprovalRequest {
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
PermissionRequestPayload {
tool_name: HookToolName::new(self.hook_tool_name.clone()),
tool_input: self
.arguments
.clone()
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
}
}
}
impl RequestPermissionsApprovalRequest {
pub(crate) fn request_permissions_event(&self) -> RequestPermissionsEvent {
RequestPermissionsEvent {
call_id: self.id.clone(),
turn_id: self.turn_id.clone(),
reason: self.reason.clone(),
permissions: self.permissions.clone(),
cwd: Some(self.cwd.clone()),
}
}
}
impl From<CommandApprovalRequest> for ApprovalRequest {
fn from(value: CommandApprovalRequest) -> Self {
Self::Command(value)
}
}
impl From<ApplyPatchApprovalRequest> for ApprovalRequest {
fn from(value: ApplyPatchApprovalRequest) -> Self {
Self::ApplyPatch(value)
}
}
impl From<McpToolCallApprovalRequest> for ApprovalRequest {
fn from(value: McpToolCallApprovalRequest) -> Self {
Self::McpToolCall(value)
}
}
impl From<RequestPermissionsApprovalRequest> for ApprovalRequest {
fn from(value: RequestPermissionsApprovalRequest) -> Self {
Self::RequestPermissions(value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GuardianNetworkAccessTrigger {
pub(crate) call_id: String,
pub(crate) tool_name: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) sandbox_permissions: crate::sandboxing::SandboxPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) justification: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tty: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct GuardianMcpAnnotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) destructive_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) open_world_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) read_only_hint: Option<bool>,
}
#[derive(Serialize)]
struct CommandApprovalAction<'a> {
tool: &'a str,
command: &'a [String],
cwd: &'a Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
justification: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tty: Option<bool>,
}
#[cfg(unix)]
#[derive(Serialize)]
struct ExecveApprovalAction<'a> {
tool: &'a str,
program: &'a str,
argv: &'a [String],
cwd: &'a Path,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a AdditionalPermissionProfile>,
}
#[derive(Serialize)]
struct McpToolCallApprovalAction<'a> {
tool: &'static str,
server: &'a str,
tool_name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
arguments: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_id: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_name: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_description: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_title: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_description: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
annotations: Option<&'a GuardianMcpAnnotations>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkAccessApprovalAction<'a> {
tool: &'static str,
target: &'a str,
host: &'a str,
protocol: NetworkApprovalProtocol,
port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
trigger: Option<&'a GuardianNetworkAccessTrigger>,
}
#[derive(Serialize)]
struct RequestPermissionsApprovalAction<'a> {
tool: &'static str,
turn_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a String>,
permissions: &'a RequestPermissionProfile,
}
fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result<Value> {
serde_json::to_value(value)
}
fn serialize_command_guardian_action(
tool: &'static str,
command: &[String],
cwd: &Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<&AdditionalPermissionProfile>,
justification: Option<&String>,
tty: Option<bool>,
) -> serde_json::Result<Value> {
serialize_guardian_action(CommandApprovalAction {
tool,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
tty,
})
}
fn command_assessment_action(
source: GuardianCommandSource,
command: &[String],
cwd: &AbsolutePathBuf,
) -> GuardianAssessmentAction {
GuardianAssessmentAction::Command {
source,
command: codex_shell_command::parse_command::shlex_join(command),
cwd: cwd.clone(),
}
}
#[cfg(unix)]
fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str {
match source {
GuardianCommandSource::Shell => "shell",
GuardianCommandSource::UnifiedExec => "exec_command",
}
}
fn truncate_guardian_action_value(value: Value) -> (Value, bool) {
match value {
Value::String(text) => {
let (text, truncated) =
guardian_truncate_text(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS);
(Value::String(text), truncated)
}
Value::Array(values) => {
let mut truncated = false;
let values = values
.into_iter()
.map(|value| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
value
})
.collect::<Vec<_>>();
(Value::Array(values), truncated)
}
Value::Object(values) => {
let mut entries = values.into_iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut truncated = false;
let values = entries
.into_iter()
.map(|(key, value)| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
(key, value)
})
.collect();
(Value::Object(values), truncated)
}
other => (other, false),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FormattedGuardianAction {
pub(crate) text: String,
pub(crate) truncated: bool,
}
pub(crate) fn guardian_approval_request_to_json(
action: &ApprovalRequest,
) -> serde_json::Result<Value> {
match action {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
..
}) => serialize_command_guardian_action(
"shell",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
/*tty*/ None,
),
ApprovalRequest::Command(CommandApprovalRequest::ExecCommand {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
tty,
..
}) => serialize_command_guardian_action(
"exec_command",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
Some(*tty),
),
#[cfg(unix)]
ApprovalRequest::Command(CommandApprovalRequest::Execve {
id: _,
source,
program,
argv,
cwd,
additional_permissions,
}) => serialize_guardian_action(ExecveApprovalAction {
tool: guardian_command_source_tool_name(*source),
program,
argv,
cwd,
additional_permissions: additional_permissions.as_ref(),
}),
ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: _,
cwd,
files,
changes: _,
patch,
}) => Ok(serde_json::json!({
"tool": "apply_patch",
"cwd": cwd,
"files": files,
"patch": patch,
})),
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: _,
turn_id: _,
target,
host,
protocol,
port,
trigger,
..
}) => serialize_guardian_action(NetworkAccessApprovalAction {
tool: "network_access",
target,
host,
protocol: *protocol,
port: *port,
trigger: trigger.as_ref(),
}),
ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
id: _,
server,
tool_name,
arguments,
connector_id,
connector_name,
connector_description,
tool_title,
tool_description,
annotations,
..
}) => serialize_guardian_action(McpToolCallApprovalAction {
tool: "mcp_tool_call",
server,
tool_name,
arguments: arguments.as_ref(),
connector_id: connector_id.as_ref(),
connector_name: connector_name.as_ref(),
connector_description: connector_description.as_ref(),
tool_title: tool_title.as_ref(),
tool_description: tool_description.as_ref(),
annotations: annotations.as_ref(),
}),
ApprovalRequest::RequestPermissions(RequestPermissionsApprovalRequest {
id: _,
turn_id,
reason,
permissions,
..
}) => serialize_guardian_action(RequestPermissionsApprovalAction {
tool: "request_permissions",
turn_id,
reason: reason.as_ref(),
permissions,
}),
}
}
pub(crate) fn guardian_assessment_action(action: &ApprovalRequest) -> GuardianAssessmentAction {
match action {
ApprovalRequest::Command(CommandApprovalRequest::Shell { command, cwd, .. }) => {
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
}
ApprovalRequest::Command(CommandApprovalRequest::ExecCommand { command, cwd, .. }) => {
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
}
#[cfg(unix)]
ApprovalRequest::Command(CommandApprovalRequest::Execve {
source,
program,
argv,
cwd,
..
}) => GuardianAssessmentAction::Execve {
source: *source,
program: program.clone(),
argv: argv.clone(),
cwd: cwd.clone(),
},
ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { cwd, files, .. }) => {
GuardianAssessmentAction::ApplyPatch {
cwd: cwd.clone(),
files: files.clone(),
}
}
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: _id,
turn_id: _turn_id,
target,
host,
protocol,
port,
trigger: _trigger,
..
}) => GuardianAssessmentAction::NetworkAccess {
target: target.clone(),
host: host.clone(),
protocol: *protocol,
port: *port,
},
ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
server,
tool_name,
connector_id,
connector_name,
tool_title,
..
}) => GuardianAssessmentAction::McpToolCall {
server: server.clone(),
tool_name: tool_name.clone(),
connector_id: connector_id.clone(),
connector_name: connector_name.clone(),
tool_title: tool_title.clone(),
},
ApprovalRequest::RequestPermissions(RequestPermissionsApprovalRequest {
reason,
permissions,
..
}) => GuardianAssessmentAction::RequestPermissions {
reason: reason.clone(),
permissions: permissions.clone(),
},
}
}
pub(crate) fn guardian_reviewed_action(request: &ApprovalRequest) -> GuardianReviewedAction {
match request {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
sandbox_permissions,
additional_permissions,
..
}) => GuardianReviewedAction::Shell {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
},
ApprovalRequest::Command(CommandApprovalRequest::ExecCommand {
sandbox_permissions,
additional_permissions,
tty,
..
}) => GuardianReviewedAction::UnifiedExec {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
tty: *tty,
},
#[cfg(unix)]
ApprovalRequest::Command(CommandApprovalRequest::Execve {
source,
program,
additional_permissions,
..
}) => GuardianReviewedAction::Execve {
source: *source,
program: program.clone(),
additional_permissions: additional_permissions.clone(),
},
ApprovalRequest::ApplyPatch(..) => GuardianReviewedAction::ApplyPatch {},
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
protocol, port, ..
}) => GuardianReviewedAction::NetworkAccess {
protocol: *protocol,
port: *port,
},
ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
server,
tool_name,
connector_id,
connector_name,
tool_title,
..
}) => GuardianReviewedAction::McpToolCall {
server: server.clone(),
tool_name: tool_name.clone(),
connector_id: connector_id.clone(),
connector_name: connector_name.clone(),
tool_title: tool_title.clone(),
},
ApprovalRequest::RequestPermissions(..) => GuardianReviewedAction::RequestPermissions {},
}
}
pub(crate) fn guardian_request_target_item_id(request: &ApprovalRequest) -> Option<&str> {
match request {
ApprovalRequest::Command(CommandApprovalRequest::Shell { id, .. })
| ApprovalRequest::Command(CommandApprovalRequest::ExecCommand { id, .. })
| ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { id, .. })
| ApprovalRequest::McpToolCall(McpToolCallApprovalRequest { id, .. })
| ApprovalRequest::RequestPermissions(RequestPermissionsApprovalRequest { id, .. }) => {
Some(id)
}
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess { .. }) => None,
#[cfg(unix)]
ApprovalRequest::Command(CommandApprovalRequest::Execve { id, .. }) => Some(id),
}
}
pub(crate) fn guardian_request_turn_id<'a>(
request: &'a ApprovalRequest,
default_turn_id: &'a str,
) -> &'a str {
match request {
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess { turn_id, .. })
| ApprovalRequest::RequestPermissions(RequestPermissionsApprovalRequest {
turn_id, ..
}) => turn_id,
ApprovalRequest::Command(CommandApprovalRequest::Shell { .. })
| ApprovalRequest::Command(CommandApprovalRequest::ExecCommand { .. })
| ApprovalRequest::ApplyPatch(..)
| ApprovalRequest::McpToolCall(..) => default_turn_id,
#[cfg(unix)]
ApprovalRequest::Command(CommandApprovalRequest::Execve { .. }) => default_turn_id,
}
}
pub(crate) fn format_guardian_action_pretty(
action: &ApprovalRequest,
) -> serde_json::Result<FormattedGuardianAction> {
let value = guardian_approval_request_to_json(action)?;
let (value, truncated) = truncate_guardian_action_value(value);
Ok(FormattedGuardianAction {
text: serde_json::to_string_pretty(&value)?,
truncated,
})
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::FileChange;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
#[test]
fn exec_approval_event_is_projected_from_shell_request() {
let request = CommandApprovalRequest::Shell {
id: "call-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
hook_command: "echo hi".to_string(),
cwd: test_path_buf("/tmp").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("because".to_string()),
};
let event = request.exec_approval_event(
"turn-1".to_string(),
Some("approval-1".to_string()),
Some("retry".to_string()),
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
/*proposed_network_policy_amendments*/ None,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
);
assert_eq!(event.call_id, "call-1");
assert_eq!(event.approval_id.as_deref(), Some("approval-1"));
assert_eq!(event.turn_id, "turn-1");
assert_eq!(event.command, vec!["echo".to_string(), "hi".to_string()]);
assert_eq!(event.reason.as_deref(), Some("retry"));
assert_eq!(
event.available_decisions,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort])
);
}
#[test]
fn apply_patch_approval_event_is_projected_from_request() {
let path = test_path_buf("/tmp/file.txt");
let abs_path = path.abs();
let request = ApplyPatchApprovalRequest {
id: "call-1".to_string(),
cwd: test_path_buf("/tmp").abs(),
files: vec![abs_path],
changes: HashMap::from([(
path.clone(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
patch: "*** Begin Patch".to_string(),
};
let event = request.apply_patch_approval_event(
"turn-1".to_string(),
Some("needs write".to_string()),
/*grant_root*/ None,
);
assert_eq!(event.call_id, "call-1");
assert_eq!(event.turn_id, "turn-1");
assert_eq!(event.reason.as_deref(), Some("needs write"));
assert_eq!(
event.changes,
HashMap::from([(
path,
FileChange::Add {
content: "hello".to_string(),
},
)])
);
}
#[test]
fn request_permissions_event_is_projected_from_request() {
let request = RequestPermissionsApprovalRequest {
id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
reason: Some("need outbound network".to_string()),
permissions: RequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
file_system: None,
},
cwd: test_path_buf("/tmp").abs(),
};
let event = request.request_permissions_event();
assert_eq!(event.call_id, "call-1");
assert_eq!(event.turn_id, "turn-1");
assert_eq!(event.reason.as_deref(), Some("need outbound network"));
assert_eq!(
event.permissions,
RequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
file_system: None,
}
);
assert_eq!(event.cwd, Some(test_path_buf("/tmp").abs()));
}
#[test]
fn network_exec_approval_event_is_projected_from_request() {
let request = CommandApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
hook_command: "curl https://example.com".to_string(),
cwd: test_path_buf("/tmp").abs(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: None,
};
let event = request.exec_approval_event(
"ignored-turn".to_string(),
/*approval_id*/ None,
Some("need network".to_string()),
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
/*proposed_network_policy_amendments*/ None,
/*available_decisions*/ None,
);
assert_eq!(event.call_id, "network-1");
assert_eq!(event.turn_id, "turn-1");
assert_eq!(
event.command,
vec![
"network-access".to_string(),
"https://example.com:443".to_string()
]
);
assert_eq!(event.cwd, test_path_buf("/tmp").abs());
assert_eq!(event.reason.as_deref(), Some("need network"));
assert_eq!(
event.network_approval_context,
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
})
);
assert_eq!(
event.proposed_network_policy_amendments,
Some(vec![
NetworkPolicyAmendment {
host: "example.com".to_string(),
action: codex_protocol::approvals::NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: "example.com".to_string(),
action: codex_protocol::approvals::NetworkPolicyRuleAction::Deny,
},
])
);
}
}

View File

@@ -12,7 +12,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RequestUserInputEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::Submission;
@@ -23,24 +22,24 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use codex_shell_command::parse_command::shlex_join;
use serde_json::Value;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use crate::approval_request::ApplyPatchApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::config::Config;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
use crate::guardian::spawn_approval_request_review;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
use crate::mcp_tool_call::build_mcp_tool_approval_request;
use crate::mcp_tool_call::is_mcp_tool_approval_question_id;
use crate::mcp_tool_call::lookup_mcp_tool_metadata;
use crate::mcp_tool_call::mcp_tool_approval_compat_response;
use crate::session::Codex;
use crate::session::CodexSpawnArgs;
use crate::session::CodexSpawnOk;
@@ -452,25 +451,28 @@ async fn handle_exec_approval(
available_decisions,
..
} = event;
let hook_command = shlex_join(&command);
let approval_request = CommandApprovalRequest::Shell {
id: call_id.clone(),
command,
hook_command,
cwd,
sandbox_permissions: if additional_permissions.is_some() {
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions
} else {
crate::sandboxing::SandboxPermissions::UseDefault
},
additional_permissions,
justification: None,
};
let decision = if routes_approval_to_guardian(parent_ctx) {
let review_cancel = cancel_token.child_token();
let review_rx = spawn_approval_request_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
GuardianApprovalRequest::Shell {
id: call_id.clone(),
command,
cwd,
sandbox_permissions: if additional_permissions.is_some() {
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions
} else {
crate::sandboxing::SandboxPermissions::UseDefault
},
additional_permissions,
justification: None,
},
reason,
approval_request.clone().into(),
reason.clone(),
GuardianApprovalRequestSource::DelegatedSubagent,
review_cancel.clone(),
);
@@ -484,16 +486,13 @@ async fn handle_exec_approval(
.await
} else {
await_approval_with_cancel(
parent_session.request_command_approval(
parent_session.request_command_approval_for_request(
parent_ctx,
call_id,
approval_request,
approval_id,
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
available_decisions,
),
parent_session,
@@ -530,49 +529,50 @@ async fn handle_patch_approval(
..
} = event;
let approval_id = call_id.clone();
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
let files = changes
let patch = changes
.iter()
.map(|(path, change)| match change {
codex_protocol::protocol::FileChange::Add { content } => {
format!("*** Add File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Delete { content } => {
format!("*** Delete File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
} => {
if let Some(move_path) = move_path {
format!(
"*** Update File: {}\n*** Move to: {}\n{}",
path.display(),
move_path.display(),
unified_diff
)
} else {
format!("*** Update File: {}\n{}", path.display(), unified_diff)
}
}
})
.collect::<Vec<_>>()
.join("\n");
let approval_request = ApplyPatchApprovalRequest {
id: approval_id.clone(),
cwd: parent_ctx.cwd.clone(),
files: changes
.keys()
.map(|path| parent_ctx.cwd.join(path))
.collect::<Vec<_>>();
.collect::<Vec<_>>(),
changes: changes.clone(),
patch,
};
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
let review_cancel = cancel_token.child_token();
let patch = changes
.iter()
.map(|(path, change)| match change {
codex_protocol::protocol::FileChange::Add { content } => {
format!("*** Add File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Delete { content } => {
format!("*** Delete File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
} => {
if let Some(move_path) = move_path {
format!(
"*** Update File: {}\n*** Move to: {}\n{}",
path.display(),
move_path.display(),
unified_diff
)
} else {
format!("*** Update File: {}\n{}", path.display(), unified_diff)
}
}
})
.collect::<Vec<_>>()
.join("\n");
let review_rx = spawn_approval_request_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.clone(),
files,
patch,
},
approval_request.clone().into(),
reason.clone(),
GuardianApprovalRequestSource::DelegatedSubagent,
review_cancel.clone(),
@@ -594,7 +594,7 @@ async fn handle_patch_approval(
decision
} else {
let decision_rx = parent_session
.request_patch_approval(parent_ctx, call_id, changes, reason, grant_root)
.request_patch_approval_for_request(parent_ctx, approval_request, reason, grant_root)
.await;
await_approval_with_cancel(
async move { decision_rx.await.unwrap_or_default() },
@@ -684,12 +684,18 @@ async fn maybe_auto_review_mcp_request_user_input(
&invocation.tool,
)
.await;
let approval_request = build_mcp_tool_approval_request(
&event.call_id,
&invocation.tool,
&invocation,
metadata.as_ref(),
);
let review_cancel = cancel_token.child_token();
let review_rx = spawn_approval_request_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()),
approval_request.clone(),
/*retry_reason*/ None,
GuardianApprovalRequestSource::DelegatedSubagent,
review_cancel.clone(),
@@ -702,32 +708,7 @@ async fn maybe_auto_review_mcp_request_user_input(
Some(&review_cancel),
)
.await;
let selected_label = match decision {
ReviewDecision::ApprovedForSession => question
.options
.as_ref()
.and_then(|options| {
options
.iter()
.find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
})
.map(|option| option.label.clone())
.unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()),
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(),
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()
}
};
Some(RequestUserInputResponse {
answers: HashMap::from([(
question.id.clone(),
codex_protocol::request_user_input::RequestUserInputAnswer {
answers: vec![selected_label],
},
)]),
})
mcp_tool_approval_compat_response(&approval_request, question, decision)
}
async fn handle_request_permissions(

View File

@@ -1,14 +1,15 @@
use super::*;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX;
use async_channel::bounded;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianCommandSource;
@@ -384,6 +385,91 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
);
}
#[tokio::test]
async fn handle_patch_approval_uses_tool_call_id_for_round_trip() {
let (parent_session, parent_ctx, rx_events) =
crate::session::tests::make_session_and_context_with_rx().await;
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let codex = Arc::new(Codex {
tx_sub,
rx_event: rx_events_child,
agent_status,
session: Arc::clone(&parent_session),
session_loop_termination: completed_session_loop_termination(),
});
let call_id = "patch-call-1".to_string();
let changes = HashMap::from([(
std::path::PathBuf::from("file.txt"),
FileChange::Update {
unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(),
move_path: None,
},
)]);
let expected_changes = changes.clone();
let cancel_token = CancellationToken::new();
let handle = tokio::spawn({
let codex = Arc::clone(&codex);
let parent_session = Arc::clone(&parent_session);
let parent_ctx = Arc::clone(&parent_ctx);
let cancel_token = cancel_token.clone();
let call_id = call_id.clone();
async move {
handle_patch_approval(
codex.as_ref(),
"child-turn-1".to_string(),
&parent_session,
&parent_ctx,
ApplyPatchApprovalRequestEvent {
call_id,
turn_id: "child-turn-1".to_string(),
changes,
reason: Some("needs write".to_string()),
grant_root: None,
},
&cancel_token,
)
.await;
}
});
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
.await
.expect("patch approval event timed out")
.expect("patch approval event missing");
let EventMsg::ApplyPatchApprovalRequest(request) = request_event.msg else {
panic!("expected ApplyPatchApprovalRequest event");
};
assert_eq!(request.call_id, call_id.clone());
assert_eq!(request.changes, expected_changes);
parent_session
.notify_approval(&call_id, ReviewDecision::Approved)
.await;
timeout(Duration::from_secs(1), handle)
.await
.expect("handle_patch_approval hung")
.expect("handle_patch_approval join error");
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("patch approval response timed out")
.expect("patch approval response missing");
assert_eq!(
submission.op,
Op::PatchApproval {
id: call_id,
decision: ReviewDecision::Approved,
}
);
}
#[tokio::test]
async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
let (parent_session, parent_ctx, _rx_events) =
@@ -417,7 +503,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
call_id: "call-1".to_string(),
turn_id: "child-turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
id: format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"),
id: "mcp_tool_call_approval_call-1".to_string(),
header: "Approve app tool call?".to_string(),
question: "Allow this app tool?".to_string(),
is_other: false,
@@ -433,7 +519,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
response,
Some(RequestUserInputResponse {
answers: HashMap::from([(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"),
"mcp_tool_call_approval_call-1".to_string(),
RequestUserInputAnswer {
answers: vec![MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()],
},

View File

@@ -1,541 +0,0 @@
use std::path::Path;
use codex_analytics::GuardianReviewedAction;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianCommandSource;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Serialize;
use serde_json::Value;
use super::GUARDIAN_MAX_ACTION_STRING_TOKENS;
use super::prompt::guardian_truncate_text;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum GuardianApprovalRequest {
Shell {
id: String,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
},
ExecCommand {
id: String,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
tty: bool,
},
#[cfg(unix)]
Execve {
id: String,
source: GuardianCommandSource,
program: String,
argv: Vec<String>,
cwd: AbsolutePathBuf,
additional_permissions: Option<AdditionalPermissionProfile>,
},
ApplyPatch {
id: String,
cwd: AbsolutePathBuf,
files: Vec<AbsolutePathBuf>,
patch: String,
},
NetworkAccess {
id: String,
turn_id: String,
target: String,
host: String,
protocol: NetworkApprovalProtocol,
port: u16,
trigger: Option<GuardianNetworkAccessTrigger>,
},
McpToolCall {
id: String,
server: String,
tool_name: String,
arguments: Option<Value>,
connector_id: Option<String>,
connector_name: Option<String>,
connector_description: Option<String>,
tool_title: Option<String>,
tool_description: Option<String>,
annotations: Option<GuardianMcpAnnotations>,
},
RequestPermissions {
id: String,
turn_id: String,
reason: Option<String>,
permissions: RequestPermissionProfile,
},
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GuardianNetworkAccessTrigger {
pub(crate) call_id: String,
pub(crate) tool_name: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) sandbox_permissions: crate::sandboxing::SandboxPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) justification: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tty: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct GuardianMcpAnnotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) destructive_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) open_world_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) read_only_hint: Option<bool>,
}
#[derive(Serialize)]
struct CommandApprovalAction<'a> {
tool: &'a str,
command: &'a [String],
cwd: &'a Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
justification: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tty: Option<bool>,
}
#[cfg(unix)]
#[derive(Serialize)]
struct ExecveApprovalAction<'a> {
tool: &'a str,
program: &'a str,
argv: &'a [String],
cwd: &'a Path,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a AdditionalPermissionProfile>,
}
#[derive(Serialize)]
struct McpToolCallApprovalAction<'a> {
tool: &'static str,
server: &'a str,
tool_name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
arguments: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_id: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_name: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
connector_description: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_title: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_description: Option<&'a String>,
#[serde(skip_serializing_if = "Option::is_none")]
annotations: Option<&'a GuardianMcpAnnotations>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkAccessApprovalAction<'a> {
tool: &'static str,
target: &'a str,
host: &'a str,
protocol: NetworkApprovalProtocol,
port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
trigger: Option<&'a GuardianNetworkAccessTrigger>,
}
#[derive(Serialize)]
struct RequestPermissionsApprovalAction<'a> {
tool: &'static str,
turn_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a String>,
permissions: &'a RequestPermissionProfile,
}
fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result<Value> {
serde_json::to_value(value)
}
fn serialize_command_guardian_action(
tool: &'static str,
command: &[String],
cwd: &Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<&AdditionalPermissionProfile>,
justification: Option<&String>,
tty: Option<bool>,
) -> serde_json::Result<Value> {
serialize_guardian_action(CommandApprovalAction {
tool,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
tty,
})
}
fn command_assessment_action(
source: GuardianCommandSource,
command: &[String],
cwd: &AbsolutePathBuf,
) -> GuardianAssessmentAction {
GuardianAssessmentAction::Command {
source,
command: codex_shell_command::parse_command::shlex_join(command),
cwd: cwd.clone(),
}
}
#[cfg(unix)]
fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str {
match source {
GuardianCommandSource::Shell => "shell",
GuardianCommandSource::UnifiedExec => "exec_command",
}
}
fn truncate_guardian_action_value(value: Value) -> (Value, bool) {
match value {
Value::String(text) => {
let (text, truncated) =
guardian_truncate_text(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS);
(Value::String(text), truncated)
}
Value::Array(values) => {
let mut truncated = false;
let values = values
.into_iter()
.map(|value| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
value
})
.collect::<Vec<_>>();
(Value::Array(values), truncated)
}
Value::Object(values) => {
let mut entries = values.into_iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut truncated = false;
let values = entries
.into_iter()
.map(|(key, value)| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
(key, value)
})
.collect();
(Value::Object(values), truncated)
}
other => (other, false),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FormattedGuardianAction {
pub(crate) text: String,
pub(crate) truncated: bool,
}
pub(crate) fn guardian_approval_request_to_json(
action: &GuardianApprovalRequest,
) -> serde_json::Result<Value> {
match action {
GuardianApprovalRequest::Shell {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
} => serialize_command_guardian_action(
"shell",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
/*tty*/ None,
),
GuardianApprovalRequest::ExecCommand {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
tty,
} => serialize_command_guardian_action(
"exec_command",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
Some(*tty),
),
#[cfg(unix)]
GuardianApprovalRequest::Execve {
id: _,
source,
program,
argv,
cwd,
additional_permissions,
} => serialize_guardian_action(ExecveApprovalAction {
tool: guardian_command_source_tool_name(*source),
program,
argv,
cwd,
additional_permissions: additional_permissions.as_ref(),
}),
GuardianApprovalRequest::ApplyPatch {
id: _,
cwd,
files,
patch,
} => Ok(serde_json::json!({
"tool": "apply_patch",
"cwd": cwd,
"files": files,
"patch": patch,
})),
GuardianApprovalRequest::NetworkAccess {
id: _,
turn_id: _,
target,
host,
protocol,
port,
trigger,
} => serialize_guardian_action(NetworkAccessApprovalAction {
tool: "network_access",
target,
host,
protocol: *protocol,
port: *port,
trigger: trigger.as_ref(),
}),
GuardianApprovalRequest::McpToolCall {
id: _,
server,
tool_name,
arguments,
connector_id,
connector_name,
connector_description,
tool_title,
tool_description,
annotations,
} => serialize_guardian_action(McpToolCallApprovalAction {
tool: "mcp_tool_call",
server,
tool_name,
arguments: arguments.as_ref(),
connector_id: connector_id.as_ref(),
connector_name: connector_name.as_ref(),
connector_description: connector_description.as_ref(),
tool_title: tool_title.as_ref(),
tool_description: tool_description.as_ref(),
annotations: annotations.as_ref(),
}),
GuardianApprovalRequest::RequestPermissions {
id: _,
turn_id,
reason,
permissions,
} => serialize_guardian_action(RequestPermissionsApprovalAction {
tool: "request_permissions",
turn_id,
reason: reason.as_ref(),
permissions,
}),
}
}
pub(crate) fn guardian_assessment_action(
action: &GuardianApprovalRequest,
) -> GuardianAssessmentAction {
match action {
GuardianApprovalRequest::Shell { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
}
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
}
#[cfg(unix)]
GuardianApprovalRequest::Execve {
source,
program,
argv,
cwd,
..
} => GuardianAssessmentAction::Execve {
source: *source,
program: program.clone(),
argv: argv.clone(),
cwd: cwd.clone(),
},
GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => {
GuardianAssessmentAction::ApplyPatch {
cwd: cwd.clone(),
files: files.clone(),
}
}
GuardianApprovalRequest::NetworkAccess {
id: _id,
turn_id: _turn_id,
target,
host,
protocol,
port,
trigger: _trigger,
} => GuardianAssessmentAction::NetworkAccess {
target: target.clone(),
host: host.clone(),
protocol: *protocol,
port: *port,
},
GuardianApprovalRequest::McpToolCall {
server,
tool_name,
connector_id,
connector_name,
tool_title,
..
} => GuardianAssessmentAction::McpToolCall {
server: server.clone(),
tool_name: tool_name.clone(),
connector_id: connector_id.clone(),
connector_name: connector_name.clone(),
tool_title: tool_title.clone(),
},
GuardianApprovalRequest::RequestPermissions {
reason,
permissions,
..
} => GuardianAssessmentAction::RequestPermissions {
reason: reason.clone(),
permissions: permissions.clone(),
},
}
}
pub(crate) fn guardian_reviewed_action(
request: &GuardianApprovalRequest,
) -> GuardianReviewedAction {
match request {
GuardianApprovalRequest::Shell {
sandbox_permissions,
additional_permissions,
..
} => GuardianReviewedAction::Shell {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
},
GuardianApprovalRequest::ExecCommand {
sandbox_permissions,
additional_permissions,
tty,
..
} => GuardianReviewedAction::UnifiedExec {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
tty: *tty,
},
#[cfg(unix)]
GuardianApprovalRequest::Execve {
source,
program,
additional_permissions,
..
} => GuardianReviewedAction::Execve {
source: *source,
program: program.clone(),
additional_permissions: additional_permissions.clone(),
},
GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {},
GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => {
GuardianReviewedAction::NetworkAccess {
protocol: *protocol,
port: *port,
}
}
GuardianApprovalRequest::McpToolCall {
server,
tool_name,
connector_id,
connector_name,
tool_title,
..
} => GuardianReviewedAction::McpToolCall {
server: server.clone(),
tool_name: tool_name.clone(),
connector_id: connector_id.clone(),
connector_name: connector_name.clone(),
tool_title: tool_title.clone(),
},
GuardianApprovalRequest::RequestPermissions { .. } => {
GuardianReviewedAction::RequestPermissions {}
}
}
}
pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> {
match request {
GuardianApprovalRequest::Shell { id, .. }
| GuardianApprovalRequest::ExecCommand { id, .. }
| GuardianApprovalRequest::ApplyPatch { id, .. }
| GuardianApprovalRequest::McpToolCall { id, .. }
| GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id),
GuardianApprovalRequest::NetworkAccess { .. } => None,
#[cfg(unix)]
GuardianApprovalRequest::Execve { id, .. } => Some(id),
}
}
pub(crate) fn guardian_request_turn_id<'a>(
request: &'a GuardianApprovalRequest,
default_turn_id: &'a str,
) -> &'a str {
match request {
GuardianApprovalRequest::NetworkAccess { turn_id, .. }
| GuardianApprovalRequest::RequestPermissions { turn_id, .. } => turn_id,
GuardianApprovalRequest::Shell { .. }
| GuardianApprovalRequest::ExecCommand { .. }
| GuardianApprovalRequest::ApplyPatch { .. }
| GuardianApprovalRequest::McpToolCall { .. } => default_turn_id,
#[cfg(unix)]
GuardianApprovalRequest::Execve { .. } => default_turn_id,
}
}
pub(crate) fn format_guardian_action_pretty(
action: &GuardianApprovalRequest,
) -> serde_json::Result<FormattedGuardianAction> {
let value = guardian_approval_request_to_json(action)?;
let (value, truncated) = truncate_guardian_action_value(value);
Ok(FormattedGuardianAction {
text: serde_json::to_string_pretty(&value)?,
truncated,
})
}

View File

@@ -11,7 +11,6 @@
//! 3. Fail closed on timeout, execution failure, or malformed output.
//! 4. Apply the guardian's explicit allow/deny outcome.
mod approval_request;
mod prompt;
mod review;
mod review_session;
@@ -23,10 +22,7 @@ use codex_protocol::protocol::GuardianAssessmentOutcome;
use serde::Deserialize;
use serde::Serialize;
pub(crate) use approval_request::GuardianApprovalRequest;
pub(crate) use approval_request::GuardianMcpAnnotations;
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
pub(crate) use approval_request::guardian_approval_request_to_json;
pub(crate) use crate::approval_request::guardian_approval_request_to_json;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
pub(crate) use review::is_guardian_reviewer_source;
@@ -51,7 +47,7 @@ const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000;
const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000;
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
pub(crate) const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
const TRUNCATION_TAG: &str = "truncated";
@@ -121,11 +117,11 @@ impl GuardianRejectionCircuitBreaker {
}
#[cfg(test)]
use approval_request::format_guardian_action_pretty;
use crate::approval_request::format_guardian_action_pretty;
#[cfg(test)]
use approval_request::guardian_assessment_action;
use crate::approval_request::guardian_assessment_action;
#[cfg(test)]
use approval_request::guardian_request_turn_id;
use crate::approval_request::guardian_request_turn_id;
#[cfg(test)]
use prompt::GuardianPromptMode;
#[cfg(test)]
@@ -144,8 +140,7 @@ use prompt::guardian_output_schema;
pub(crate) use prompt::guardian_policy_prompt;
#[cfg(test)]
pub(crate) use prompt::guardian_policy_prompt_with_config;
#[cfg(test)]
use prompt::guardian_truncate_text;
pub(crate) use prompt::guardian_truncate_text;
#[cfg(test)]
use prompt::parse_guardian_assessment;
#[cfg(test)]

View File

@@ -20,10 +20,11 @@ use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS;
use super::GUARDIAN_MAX_TOOL_ENTRY_TOKENS;
use super::GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS;
use super::GUARDIAN_RECENT_ENTRY_LIMIT;
use super::GuardianApprovalRequest;
use super::GuardianAssessment;
use super::TRUNCATION_TAG;
use super::approval_request::format_guardian_action_pretty;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::format_guardian_action_pretty;
/// Transcript entry retained for guardian review after filtering.
#[derive(Debug, PartialEq, Eq)]
@@ -89,7 +90,7 @@ pub(crate) enum GuardianPromptMode {
pub(crate) async fn build_guardian_prompt_items(
session: &Session,
retry_reason: Option<String>,
request: GuardianApprovalRequest,
request: ApprovalRequest,
mode: GuardianPromptMode,
) -> serde_json::Result<GuardianPromptItems> {
let history = session.clone_history().await;
@@ -173,7 +174,7 @@ pub(crate) async fn build_guardian_prompt_items(
push_text(format!("\n{note}\n"));
}
match &request {
GuardianApprovalRequest::NetworkAccess { trigger, .. } => {
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess { trigger, .. }) => {
push_text(">>> APPROVAL REQUEST START\n".to_string());
push_text("Below is a proposed network access request under review.\n".to_string());
if trigger.is_some() {

View File

@@ -26,20 +26,20 @@ use crate::session::turn_context::TurnContext;
use super::GUARDIAN_REVIEW_TIMEOUT;
use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::GuardianAssessment;
use super::GuardianAssessmentOutcome;
use super::GuardianRejection;
use super::GuardianRejectionCircuitBreakerAction;
use super::approval_request::guardian_assessment_action;
use super::approval_request::guardian_request_target_item_id;
use super::approval_request::guardian_request_turn_id;
use super::approval_request::guardian_reviewed_action;
use super::prompt::guardian_output_schema;
use super::prompt::parse_guardian_assessment;
use super::review_session::GuardianReviewSessionOutcome;
use super::review_session::GuardianReviewSessionParams;
use super::review_session::build_guardian_review_session_config;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::guardian_assessment_action;
use crate::approval_request::guardian_request_target_item_id;
use crate::approval_request::guardian_request_turn_id;
use crate::approval_request::guardian_reviewed_action;
const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
"The agent must not attempt to achieve the same outcome via workaround, ",
@@ -234,7 +234,7 @@ async fn run_guardian_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
request: ApprovalRequest,
retry_reason: Option<String>,
approval_request_source: GuardianApprovalRequestSource,
external_cancel: Option<CancellationToken>,
@@ -523,7 +523,7 @@ pub(crate) async fn review_approval_request(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
request: ApprovalRequest,
retry_reason: Option<String>,
) -> ReviewDecision {
// Box the delegated review future so callers do not inline the entire
@@ -544,7 +544,7 @@ pub(crate) async fn review_approval_request_with_cancel(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
request: ApprovalRequest,
retry_reason: Option<String>,
approval_request_source: GuardianApprovalRequestSource,
cancel_token: CancellationToken,
@@ -565,7 +565,7 @@ pub(crate) fn spawn_approval_request_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
request: ApprovalRequest,
retry_reason: Option<String>,
approval_request_source: GuardianApprovalRequestSource,
cancel_token: CancellationToken,
@@ -610,7 +610,7 @@ pub(crate) fn spawn_approval_request_review(
pub(super) async fn run_guardian_review_session(
session: Arc<Session>,
turn: Arc<TurnContext>,
request: GuardianApprovalRequest,
request: ApprovalRequest,
retry_reason: Option<String>,
schema: serde_json::Value,
external_cancel: Option<CancellationToken>,

View File

@@ -46,12 +46,14 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use super::GUARDIAN_REVIEW_TIMEOUT;
use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::prompt::GuardianPromptMode;
use super::prompt::GuardianTranscriptCursor;
use super::prompt::build_guardian_prompt_items;
use super::prompt::guardian_policy_prompt;
use super::prompt::guardian_policy_prompt_with_config;
use crate::approval_request::ApprovalRequest;
#[cfg(test)]
use crate::approval_request::CommandApprovalRequest;
const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
@@ -67,7 +69,7 @@ pub(crate) struct GuardianReviewSessionParams {
pub(crate) parent_session: Arc<Session>,
pub(crate) parent_turn: Arc<TurnContext>,
pub(crate) spawn_config: Config,
pub(crate) request: GuardianApprovalRequest,
pub(crate) request: ApprovalRequest,
pub(crate) retry_reason: Option<String>,
pub(crate) schema: Value,
pub(crate) model: String,
@@ -1101,14 +1103,15 @@ mod tests {
parent_session: Arc::new(session),
parent_turn: Arc::new(turn),
spawn_config,
request: GuardianApprovalRequest::Shell {
request: ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
hook_command: "git status".to_string(),
cwd,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state.".to_string()),
},
}),
retry_reason: None,
schema: super::super::prompt::guardian_output_schema(),
model,

View File

@@ -1,11 +1,19 @@
use std::collections::HashMap;
use super::*;
use crate::approval_request::ApplyPatchApprovalRequest;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::GuardianMcpAnnotations;
use crate::approval_request::GuardianNetworkAccessTrigger;
use crate::approval_request::McpToolCallApprovalRequest;
use crate::approval_request::guardian_request_target_item_id;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::Constrained;
use crate::config::ManagedFeatures;
use crate::config::NetworkProxySpec;
use crate::config::test_config;
use crate::guardian::approval_request::guardian_request_target_item_id;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::test_support;
@@ -60,7 +68,6 @@ use insta::Settings;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
@@ -312,14 +319,15 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Sandbox denied outbound git push to github.com.".to_string()),
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
},
}),
GuardianPromptMode::Full,
)
.await?;
@@ -366,14 +374,15 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
},
}),
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
@@ -404,14 +413,15 @@ async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Resul
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
},
}),
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
@@ -439,14 +449,15 @@ async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() ->
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the docs fix.".to_string()),
},
}),
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
@@ -520,14 +531,15 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-4".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push after the compaction.".to_string()),
},
}),
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
@@ -684,12 +696,13 @@ fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
#[test]
fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::Result<()> {
let patch = "line\n".repeat(100_000);
let action = GuardianApprovalRequest::ApplyPatch {
let action = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs(),
files: Vec::new(),
changes: HashMap::new(),
patch: patch.clone(),
};
});
let rendered = format_guardian_action_pretty(&action)?;
@@ -703,12 +716,13 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::
#[test]
fn format_guardian_action_pretty_reports_no_truncation_for_small_payload() -> serde_json::Result<()>
{
let action = GuardianApprovalRequest::ApplyPatch {
let action = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs(),
files: Vec::new(),
changes: HashMap::new(),
patch: "line\n".to_string(),
};
});
let rendered = format_guardian_action_pretty(&action)?;
@@ -719,10 +733,11 @@ fn format_guardian_action_pretty_reports_no_truncation_for_small_payload() -> se
#[test]
fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> {
let action = GuardianApprovalRequest::McpToolCall {
let action = ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
id: "call-1".to_string(),
server: "mcp_server".to_string(),
tool_name: "browser_navigate".to_string(),
hook_tool_name: "browser_navigate".to_string(),
arguments: Some(serde_json::json!({
"url": "https://example.com",
})),
@@ -736,7 +751,7 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json
open_world_hint: None,
read_only_hint: Some(false),
}),
};
});
assert_eq!(
guardian_approval_request_to_json(&action)?,
@@ -761,10 +776,12 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json
#[test]
fn guardian_approval_request_to_json_renders_network_access_trigger() -> serde_json::Result<()> {
let cwd = test_path_buf("/repo").abs();
let action = GuardianApprovalRequest::NetworkAccess {
let action = ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
hook_command: "curl https://example.com".to_string(),
cwd: cwd.clone(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
@@ -778,7 +795,7 @@ fn guardian_approval_request_to_json_renders_network_access_trigger() -> serde_j
justification: Some("Fetch the release metadata.".to_string()),
tty: None,
}),
};
});
assert_eq!(
guardian_approval_request_to_json(&action)?,
@@ -811,10 +828,12 @@ async fn build_guardian_prompt_items_explains_network_access_review_scope() -> a
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Network access to \"example.com\" is blocked by policy.".to_string()),
GuardianApprovalRequest::NetworkAccess {
ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
hook_command: "curl https://example.com".to_string(),
cwd: cwd.clone(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
@@ -828,7 +847,7 @@ async fn build_guardian_prompt_items_explains_network_access_review_scope() -> a
justification: Some("Fetch the release metadata.".to_string()),
tty: None,
}),
},
}),
GuardianPromptMode::Full,
)
.await?;
@@ -875,13 +894,14 @@ async fn build_guardian_prompt_items_explains_network_access_review_scope() -> a
fn guardian_assessment_action_redacts_apply_patch_patch_text() {
let cwd = test_path_buf("/tmp").abs();
let file = test_path_buf("/tmp/guardian.txt").abs();
let action = GuardianApprovalRequest::ApplyPatch {
let action = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: "patch-1".to_string(),
cwd: cwd.clone(),
files: vec![file.clone()],
changes: HashMap::new(),
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch"
.to_string(),
};
});
assert_eq!(
serde_json::to_value(guardian_assessment_action(&action)).expect("serialize action"),
@@ -895,22 +915,25 @@ fn guardian_assessment_action_redacts_apply_patch_patch_text() {
#[test]
fn guardian_request_turn_id_prefers_network_access_owner_turn() {
let network_access = GuardianApprovalRequest::NetworkAccess {
let network_access = ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
target: "https://example.com:443".to_string(),
hook_command: "network-access https://example.com:443".to_string(),
cwd: test_path_buf("/tmp").abs(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: None,
};
let apply_patch = GuardianApprovalRequest::ApplyPatch {
});
let apply_patch = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs(),
files: vec![test_path_buf("/tmp/guardian.txt").abs()],
changes: HashMap::new(),
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
};
});
assert_eq!(
guardian_request_turn_id(&network_access, "fallback-turn"),
@@ -924,10 +947,12 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
#[test]
fn guardian_request_target_item_id_omits_network_access_trigger_call_id() {
let network_access = GuardianApprovalRequest::NetworkAccess {
let network_access = ApprovalRequest::Command(CommandApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
target: "https://example.com:443".to_string(),
hook_command: "network-access https://example.com:443".to_string(),
cwd: test_path_buf("/tmp").abs(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
@@ -941,7 +966,7 @@ fn guardian_request_target_item_id_omits_network_access_trigger_call_id() {
justification: None,
tty: None,
}),
};
});
assert_eq!(guardian_request_target_item_id(&network_access), None);
}
@@ -956,13 +981,14 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
&session,
&turn,
"review-cancelled-guardian".to_string(),
GuardianApprovalRequest::ApplyPatch {
ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs(),
files: vec![test_path_buf("/tmp/guardian.txt").abs()],
changes: HashMap::new(),
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
},
}),
/*retry_reason*/ None,
GuardianApprovalRequestSource::MainTurn,
cancel_token,
@@ -1245,7 +1271,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
let request = GuardianApprovalRequest::Shell {
let request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec![
"git".to_string(),
@@ -1253,11 +1279,12 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
"origin".to_string(),
"guardian-approval-mvp".to_string(),
],
hook_command: "git push origin guardian-approval-mvp".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()),
};
});
let outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -1355,14 +1382,15 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res
let prompt = build_guardian_prompt_items(
&session,
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
hook_command: "git status".to_string(),
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
},
}),
GuardianPromptMode::Full,
)
.await?;
@@ -1429,14 +1457,15 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let first_request = GuardianApprovalRequest::Shell {
let first_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
};
});
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
@@ -1469,18 +1498,19 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
turn.as_ref(),
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-2".to_string(),
command: vec![
"git".to_string(),
"push".to_string(),
"--force-with-lease".to_string(),
],
hook_command: "git push --force-with-lease".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
};
});
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
@@ -1513,14 +1543,15 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
turn.as_ref(),
)
.await;
let third_request = GuardianApprovalRequest::Shell {
let third_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the third docs fix.".to_string()),
};
});
let third_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
@@ -1708,14 +1739,15 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
},
}),
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
@@ -1750,14 +1782,15 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
},
}),
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
@@ -1829,14 +1862,15 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
&session,
&turn,
"review-shell-guardian-error".to_string(),
GuardianApprovalRequest::Shell {
ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-guardian-error".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
},
}),
/*retry_reason*/ None,
)
.await;
@@ -1963,14 +1997,15 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
seed_guardian_parent_history(&session, &turn).await;
let initial_request = GuardianApprovalRequest::Shell {
let initial_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-guardian-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
hook_command: "git status".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state before proceeding.".to_string()),
};
});
assert_eq!(
review_approval_request(
&session,
@@ -2006,22 +2041,24 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-guardian-2".to_string(),
command: vec!["git".to_string(), "diff".to_string()],
hook_command: "git diff".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect pending changes before proceeding.".to_string()),
};
let third_request = GuardianApprovalRequest::Shell {
});
let third_request = ApprovalRequest::Command(CommandApprovalRequest::Shell {
id: "shell-guardian-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
hook_command: "git push".to_string(),
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
};
});
let session_for_second = Arc::clone(&session);
let turn_for_second = Arc::clone(&turn);

View File

@@ -6,6 +6,7 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod apply_patch;
mod approval_request;
mod apps;
mod arc_monitor;
mod client;

View File

@@ -10,6 +10,9 @@ use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use tracing::error;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::GuardianMcpAnnotations;
use crate::approval_request::McpToolCallApprovalRequest;
use crate::arc_monitor::ArcMonitorOutcome;
use crate::arc_monitor::monitor_action;
use crate::config::Config;
@@ -17,8 +20,6 @@ use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::load_global_mcp_servers;
use crate::connectors;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianMcpAnnotations;
use crate::guardian::guardian_approval_request_to_json;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
@@ -31,8 +32,6 @@ use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
@@ -885,7 +884,7 @@ struct McpToolApprovalPromptOptions {
struct McpToolApprovalElicitationRequest<'a> {
server: &'a str,
metadata: Option<&'a McpToolApprovalMetadata>,
approval_request: &'a ApprovalRequest,
tool_params: Option<&'a serde_json::Value>,
tool_params_display: Option<&'a [RenderedMcpToolApprovalParam]>,
question: RequestUserInputQuestion,
@@ -893,6 +892,14 @@ struct McpToolApprovalElicitationRequest<'a> {
prompt_options: McpToolApprovalPromptOptions,
}
#[derive(Clone, Debug, PartialEq)]
struct McpToolApprovalPrompt {
question: RequestUserInputQuestion,
message_override: Option<String>,
tool_params: Option<JsonValue>,
tool_params_display: Option<Vec<RenderedMcpToolApprovalParam>>,
}
pub(crate) const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow";
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session";
@@ -917,6 +924,7 @@ const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title";
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
@@ -933,6 +941,105 @@ struct McpToolApprovalKey {
tool_name: String,
}
/// Builds the user-facing MCP approval prompt from the canonical approval request.
fn mcp_tool_approval_prompt(
approval_request: &ApprovalRequest,
question_id: String,
prompt_options: McpToolApprovalPromptOptions,
monitor_reason: Option<&str>,
) -> Option<McpToolApprovalPrompt> {
let ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
server,
tool_name,
arguments,
connector_id,
connector_name,
tool_title,
..
}) = approval_request
else {
return None;
};
let rendered_template = render_mcp_tool_approval_template(
server,
connector_id.as_deref(),
connector_name.as_deref(),
tool_title.as_deref(),
arguments.as_ref(),
);
let tool_params_display = rendered_template
.as_ref()
.map(|rendered_template| rendered_template.tool_params_display.clone())
.or_else(|| build_mcp_tool_approval_display_params(arguments.as_ref()));
let question_override = rendered_template
.as_ref()
.map(|rendered_template| rendered_template.question.as_str());
let mut question = build_mcp_tool_approval_question(
question_id,
server,
tool_name,
connector_name.as_deref(),
prompt_options,
question_override,
);
question.question = mcp_tool_approval_question_text(question.question, monitor_reason);
Some(McpToolApprovalPrompt {
question,
message_override: rendered_template.as_ref().and_then(|rendered_template| {
monitor_reason
.is_none()
.then_some(rendered_template.elicitation_message.clone())
}),
tool_params: rendered_template
.as_ref()
.and_then(|rendered_template| rendered_template.tool_params.clone())
.or_else(|| arguments.clone()),
tool_params_display,
})
}
/// Converts a guardian MCP approval decision into the legacy RequestUserInput
/// response shape used by delegated compatibility flows.
pub(crate) fn mcp_tool_approval_compat_response(
approval_request: &ApprovalRequest,
question: &RequestUserInputQuestion,
decision: ReviewDecision,
) -> Option<RequestUserInputResponse> {
let ApprovalRequest::McpToolCall(..) = approval_request else {
return None;
};
let selected_label = match decision {
ReviewDecision::ApprovedForSession => question
.options
.as_ref()
.and_then(|options| {
options
.iter()
.find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
})
.map(|option| option.label.clone())
.unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()),
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(),
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()
}
};
Some(RequestUserInputResponse {
answers: HashMap::from([(
question.id.clone(),
RequestUserInputAnswer {
answers: vec![selected_label],
},
)]),
})
}
fn mcp_tool_approval_prompt_options(
session_approval_key: Option<&McpToolApprovalKey>,
persistent_approval_key: Option<&McpToolApprovalKey>,
@@ -1005,18 +1112,15 @@ async fn maybe_request_mcp_tool_approval(
return Some(McpToolApprovalDecision::Accept);
}
match run_permission_request_hooks(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_input: invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
},
)
let approval_request =
build_mcp_tool_approval_request(call_id, hook_tool_name, invocation, metadata);
match run_permission_request_hooks(sess, turn_context, call_id, {
let Some(payload) = approval_request.permission_request_payload() else {
unreachable!("MCP approvals always project a permission request payload");
};
payload
})
.await
{
Some(PermissionRequestDecision::Allow) => {
@@ -1041,7 +1145,7 @@ async fn maybe_request_mcp_tool_approval(
sess,
turn_context,
review_id.clone(),
build_guardian_mcp_tool_review_request(call_id, invocation, metadata),
approval_request,
monitor_reason.clone(),
)
.await;
@@ -1063,50 +1167,26 @@ async fn maybe_request_mcp_tool_approval(
tool_call_mcp_elicitation_enabled,
);
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
let rendered_template = render_mcp_tool_approval_template(
&invocation.server,
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
invocation.arguments.as_ref(),
);
let tool_params_display = rendered_template
.as_ref()
.map(|rendered_template| rendered_template.tool_params_display.clone())
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
let mut question = build_mcp_tool_approval_question(
let Some(prompt) = mcp_tool_approval_prompt(
&approval_request,
question_id.clone(),
&invocation.server,
&invocation.tool,
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
prompt_options,
rendered_template
.as_ref()
.map(|rendered_template| rendered_template.question.as_str()),
);
question.question =
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
monitor_reason.as_deref(),
) else {
unreachable!("MCP approvals always project an MCP approval prompt");
};
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
);
let request_id = rmcp::model::RequestId::String(question_id.clone().into());
let params = build_mcp_tool_approval_elicitation_request(
sess.as_ref(),
turn_context.as_ref(),
McpToolApprovalElicitationRequest {
server: &invocation.server,
metadata,
tool_params: rendered_template
.as_ref()
.and_then(|rendered_template| rendered_template.tool_params.as_ref())
.or(invocation.arguments.as_ref()),
tool_params_display: tool_params_display.as_deref(),
question,
message_override: rendered_template.as_ref().and_then(|rendered_template| {
monitor_reason
.is_none()
.then_some(rendered_template.elicitation_message.as_str())
}),
approval_request: &approval_request,
tool_params: prompt.tool_params.as_ref(),
tool_params_display: prompt.tool_params_display.as_deref(),
question: prompt.question,
message_override: prompt.message_override.as_deref(),
prompt_options,
},
);
@@ -1128,7 +1208,7 @@ async fn maybe_request_mcp_tool_approval(
}
let args = RequestUserInputArgs {
questions: vec![question],
questions: vec![prompt.question],
};
let response = sess
.request_user_input(turn_context.as_ref(), call_id.to_string(), args)
@@ -1169,7 +1249,8 @@ fn prepare_arc_request_action(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
) -> serde_json::Value {
let request = build_guardian_mcp_tool_review_request("arc-monitor", invocation, metadata);
let request =
build_mcp_tool_approval_request("arc-monitor", &invocation.tool, invocation, metadata);
match guardian_approval_request_to_json(&request) {
Ok(action) => action,
Err(error) => {
@@ -1208,15 +1289,17 @@ fn persistent_mcp_tool_approval_key(
session_mcp_tool_approval_key(invocation, metadata, approval_mode)
}
pub(crate) fn build_guardian_mcp_tool_review_request(
pub(crate) fn build_mcp_tool_approval_request(
call_id: &str,
hook_tool_name: &str,
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
) -> GuardianApprovalRequest {
GuardianApprovalRequest::McpToolCall {
) -> ApprovalRequest {
McpToolCallApprovalRequest {
id: call_id.to_string(),
server: invocation.server.clone(),
tool_name: invocation.tool.clone(),
hook_tool_name: hook_tool_name.to_string(),
arguments: invocation.arguments.clone(),
connector_id: metadata.and_then(|metadata| metadata.connector_id.clone()),
connector_name: metadata.and_then(|metadata| metadata.connector_name.clone()),
@@ -1231,6 +1314,7 @@ pub(crate) fn build_guardian_mcp_tool_review_request(
read_only_hint: annotations.read_only_hint,
}),
}
.into()
}
async fn mcp_tool_approval_decision_from_guardian(
@@ -1489,8 +1573,7 @@ fn build_mcp_tool_approval_elicitation_request(
server_name: request.server.to_string(),
request: McpServerElicitationRequest::Form {
meta: build_mcp_tool_approval_elicitation_meta(
request.server,
request.metadata,
request.approval_request,
request.tool_params,
request.tool_params_display,
request.prompt_options,
@@ -1507,16 +1590,28 @@ fn build_mcp_tool_approval_elicitation_request(
}
fn build_mcp_tool_approval_elicitation_meta(
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
approval_request: &ApprovalRequest,
tool_params: Option<&JsonValue>,
tool_params_display: Option<&[RenderedMcpToolApprovalParam]>,
prompt_options: McpToolApprovalPromptOptions,
) -> Option<serde_json::Value> {
) -> Option<JsonValue> {
let ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
server,
connector_id,
connector_name,
connector_description,
tool_title,
tool_description,
..
}) = approval_request
else {
return None;
};
let mut meta = serde_json::Map::new();
meta.insert(
MCP_TOOL_APPROVAL_KIND_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL.to_string()),
JsonValue::String(MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL.to_string()),
);
match (
prompt_options.allow_session_remember,
@@ -1534,57 +1629,53 @@ fn build_mcp_tool_approval_elicitation_meta(
(true, false) => {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()),
JsonValue::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()),
);
}
(false, true) => {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_ALWAYS.to_string()),
JsonValue::String(MCP_TOOL_APPROVAL_PERSIST_ALWAYS.to_string()),
);
}
(false, false) => {}
}
if let Some(metadata) = metadata {
if let Some(tool_title) = metadata.tool_title.as_ref() {
if let Some(tool_title) = tool_title.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY.to_string(),
JsonValue::String(tool_title.clone()),
);
}
if let Some(tool_description) = tool_description.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY.to_string(),
JsonValue::String(tool_description.clone()),
);
}
if server == CODEX_APPS_MCP_SERVER_NAME
&& (connector_id.is_some() || connector_name.is_some() || connector_description.is_some())
{
meta.insert(
MCP_TOOL_APPROVAL_SOURCE_KEY.to_string(),
JsonValue::String(MCP_TOOL_APPROVAL_SOURCE_CONNECTOR.to_string()),
);
if let Some(connector_id) = connector_id.as_deref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY.to_string(),
serde_json::Value::String(tool_title.clone()),
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY.to_string(),
JsonValue::String(connector_id.to_string()),
);
}
if let Some(tool_description) = metadata.tool_description.as_ref() {
if let Some(connector_name) = connector_name.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY.to_string(),
serde_json::Value::String(tool_description.clone()),
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY.to_string(),
JsonValue::String(connector_name.clone()),
);
}
if server == CODEX_APPS_MCP_SERVER_NAME
&& (metadata.connector_id.is_some()
|| metadata.connector_name.is_some()
|| metadata.connector_description.is_some())
{
if let Some(connector_description) = connector_description.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_SOURCE_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_SOURCE_CONNECTOR.to_string()),
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY.to_string(),
JsonValue::String(connector_description.clone()),
);
if let Some(connector_id) = metadata.connector_id.as_deref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY.to_string(),
serde_json::Value::String(connector_id.to_string()),
);
}
if let Some(connector_name) = metadata.connector_name.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY.to_string(),
serde_json::Value::String(connector_name.clone()),
);
}
if let Some(connector_description) = metadata.connector_description.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY.to_string(),
serde_json::Value::String(connector_description.clone()),
);
}
}
}
if let Some(tool_params) = tool_params {
@@ -1601,22 +1692,21 @@ fn build_mcp_tool_approval_elicitation_meta(
tool_params_display,
);
}
(!meta.is_empty()).then_some(serde_json::Value::Object(meta))
(!meta.is_empty()).then_some(JsonValue::Object(meta))
}
fn build_mcp_tool_approval_display_params(
tool_params: Option<&serde_json::Value>,
) -> Option<Vec<crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam>> {
tool_params: Option<&JsonValue>,
) -> Option<Vec<RenderedMcpToolApprovalParam>> {
let tool_params = tool_params?.as_object()?;
let mut display_params = tool_params
.iter()
.map(
|(name, value)| crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam {
name: name.clone(),
value: value.clone(),
display_name: name.clone(),
},
)
.map(|(name, value)| RenderedMcpToolApprovalParam {
name: name.clone(),
value: value.clone(),
display_name: name.clone(),
})
.collect::<Vec<_>>();
display_params.sort_by(|left, right| left.name.cmp(&right.name));
Some(display_params)

View File

@@ -1,4 +1,6 @@
use super::*;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::GuardianMcpAnnotations;
use crate::config::ConfigBuilder;
use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
@@ -103,6 +105,24 @@ fn prompt_options(
}
}
fn approval_prompt_request(
server: &str,
tool_name: &str,
arguments: Option<serde_json::Value>,
metadata: Option<&McpToolApprovalMetadata>,
) -> ApprovalRequest {
build_mcp_tool_approval_request(
"call-1",
tool_name,
&McpInvocation {
server: server.to_string(),
tool: tool_name.to_string(),
arguments,
},
metadata,
)
}
fn install_mcp_permission_request_hook(
session: &mut Session,
turn_context: &TurnContext,
@@ -280,15 +300,97 @@ fn prompt_mode_does_not_allow_persistent_remember() {
#[test]
fn approval_question_text_prepends_safety_reason() {
let request = approval_prompt_request(
"custom_server",
"run_action",
/*arguments*/ None,
/*metadata*/ None,
);
assert_eq!(
mcp_tool_approval_question_text(
"Allow this action?".to_string(),
mcp_tool_approval_prompt(
&request,
"q".to_string(),
prompt_options(
/*allow_session_remember*/ false, /*allow_persistent_approval*/ false,
),
Some("This tool may contact an external system."),
),
)
.expect("mcp approval prompt")
.question
.question,
"Tool call needs your approval. Reason: This tool may contact an external system."
);
}
#[test]
fn mcp_tool_approval_compat_response_uses_session_label_when_present() {
let request = approval_prompt_request(
"custom_server",
"dangerous_tool",
/*arguments*/ None,
/*metadata*/ None,
);
let question = RequestUserInputQuestion {
id: "q-1".to_string(),
header: "Approve app tool call?".to_string(),
question: "Allow this app tool?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
description: "Remember until session ends".to_string(),
}]),
};
let response =
mcp_tool_approval_compat_response(&request, &question, ReviewDecision::ApprovedForSession)
.expect("compat response");
assert_eq!(
response.answers.get("q-1"),
Some(&RequestUserInputAnswer {
answers: vec![MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string()],
})
);
}
#[test]
fn mcp_tool_approval_compat_response_uses_synthetic_decline_for_abort() {
let request = approval_prompt_request(
"custom_server",
"dangerous_tool",
/*arguments*/ None,
/*metadata*/ None,
);
let question = RequestUserInputQuestion {
id: "q-1".to_string(),
header: "Approve app tool call?".to_string(),
question: "Allow this app tool?".to_string(),
is_other: false,
is_secret: false,
options: None,
};
let response = mcp_tool_approval_compat_response(&request, &question, ReviewDecision::Abort)
.expect("compat response");
assert_eq!(
response.answers.get("q-1"),
Some(&RequestUserInputAnswer {
answers: vec![MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()],
})
);
}
#[test]
fn mcp_tool_approval_question_id_detection_round_trips() {
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1");
assert_eq!(question_id, "mcp_tool_call_approval_call-1");
assert!(is_mcp_tool_approval_question_id(&question_id));
assert!(!is_mcp_tool_approval_question_id("other_question"));
}
#[tokio::test]
async fn mcp_tool_call_span_records_expected_fields() {
let buffer: &'static std::sync::Mutex<Vec<u8>> =
@@ -479,47 +581,43 @@ fn truncates_strings_on_char_boundaries() {
#[tokio::test]
async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() {
let (session, turn_context) = make_session_and_context().await;
let question = build_mcp_tool_approval_question(
"q".to_string(),
let metadata = approval_metadata(
Some("connector_947e0d954944416db111db556030eea6"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("create_event"),
Some("Create a calendar event."),
);
let approval_request = approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"create_event",
Some("Calendar"),
Some(serde_json::json!({
"title": "Roadmap review",
"start_time": "2026-05-01T10:00:00Z",
"attendees": ["ada@example.com"],
})),
Some(&metadata),
);
let prompt = mcp_tool_approval_prompt(
&approval_request,
"q".to_string(),
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
Some("Allow Calendar to create an event?"),
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt");
let request = build_mcp_tool_approval_elicitation_request(
&session,
&turn_context,
McpToolApprovalElicitationRequest {
server: CODEX_APPS_MCP_SERVER_NAME,
metadata: Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Create Event"),
Some("Create a calendar event."),
)),
tool_params: Some(&serde_json::json!({
"calendar_id": "primary",
"title": "Roadmap review",
})),
tool_params_display: Some(&[
RenderedMcpToolApprovalParam {
name: "calendar_id".to_string(),
value: serde_json::json!("primary"),
display_name: "Calendar".to_string(),
},
RenderedMcpToolApprovalParam {
name: "title".to_string(),
value: serde_json::json!("Roadmap review"),
display_name: "Title".to_string(),
},
]),
question,
message_override: Some("Allow Calendar to create an event?"),
approval_request: &approval_request,
tool_params: prompt.tool_params.as_ref(),
tool_params_display: prompt.tool_params_display.as_deref(),
question: prompt.question,
message_override: prompt.message_override.as_deref(),
prompt_options: prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
@@ -540,26 +638,32 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
MCP_TOOL_APPROVAL_PERSIST_ALWAYS,
],
MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "connector_947e0d954944416db111db556030eea6",
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Create Event",
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "create_event",
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Create a calendar event.",
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {
"calendar_id": "primary",
"title": "Roadmap review",
"start_time": "2026-05-01T10:00:00Z",
"attendees": ["ada@example.com"],
},
MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: [
{
"name": "calendar_id",
"value": "primary",
"display_name": "Calendar",
},
{
"name": "title",
"value": "Roadmap review",
"display_name": "Title",
},
{
"name": "start_time",
"value": "2026-05-01T10:00:00Z",
"display_name": "Start",
},
{
"name": "attendees",
"value": ["ada@example.com"],
"display_name": "Attendees",
},
],
})),
message: "Allow Calendar to create an event?".to_string(),
@@ -576,16 +680,21 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
#[test]
fn custom_mcp_tool_question_mentions_server_name() {
let question = build_mcp_tool_approval_question(
let question = mcp_tool_approval_prompt(
&approval_prompt_request(
"custom_server",
"run_action",
/*arguments*/ None,
/*metadata*/ None,
),
"q".to_string(),
"custom_server",
"run_action",
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ false, /*allow_persistent_approval*/ false,
),
/*question_override*/ None,
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt")
.question;
assert_eq!(question.header, "Approve app tool call?");
assert_eq!(
@@ -604,16 +713,21 @@ fn custom_mcp_tool_question_mentions_server_name() {
#[test]
fn codex_apps_tool_question_uses_fallback_app_label() {
let question = build_mcp_tool_approval_question(
let question = mcp_tool_approval_prompt(
&approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
/*arguments*/ None,
/*metadata*/ None,
),
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt")
.question;
assert_eq!(
question.question,
@@ -623,16 +737,28 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
#[test]
fn trusted_codex_apps_tool_question_offers_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
let metadata = approval_metadata(
Some("calendar"),
Some("Calendar"),
/*connector_description*/ None,
/*tool_title*/ None,
/*tool_description*/ None,
);
let question = mcp_tool_approval_prompt(
&approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
/*arguments*/ None,
Some(&metadata),
),
"q".to_string(),
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt")
.question;
let options = question.options.expect("options");
assert!(options.iter().any(|option| {
@@ -665,18 +791,30 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
tool_name: "run_action".to_string(),
};
let persistent_key = session_key.clone();
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
let metadata = approval_metadata(
Some("calendar"),
Some("Calendar"),
/*connector_description*/ None,
/*tool_title*/ None,
/*tool_description*/ None,
);
let question = mcp_tool_approval_prompt(
&approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
/*arguments*/ None,
Some(&metadata),
),
"q".to_string(),
mcp_tool_approval_prompt_options(
Some(&session_key),
Some(&persistent_key),
/*tool_call_mcp_elicitation_enabled*/ false,
),
/*question_override*/ None,
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt")
.question;
assert_eq!(
question
@@ -695,16 +833,21 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
#[test]
fn custom_mcp_tool_question_offers_session_remember_and_always_allow() {
let question = build_mcp_tool_approval_question(
let question = mcp_tool_approval_prompt(
&approval_prompt_request(
"custom_server",
"run_action",
/*arguments*/ None,
/*metadata*/ None,
),
"q".to_string(),
"custom_server",
"run_action",
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
/*monitor_reason*/ None,
)
.expect("mcp approval prompt")
.question;
assert_eq!(
question
@@ -1086,10 +1229,15 @@ fn accepted_elicitation_content_converts_to_request_user_input_response() {
#[test]
fn approval_elicitation_meta_marks_tool_approvals() {
let request = approval_prompt_request(
"custom_server",
"run_action",
/*arguments*/ None,
/*metadata*/ None,
);
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
/*metadata*/ None,
&request,
/*tool_params*/ None,
/*tool_params_display*/ None,
prompt_options(
@@ -1104,16 +1252,22 @@ fn approval_elicitation_meta_marks_tool_approvals() {
#[test]
fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() {
let metadata = approval_metadata(
/*connector_id*/ None,
/*connector_name*/ None,
/*connector_description*/ None,
Some("Run Action"),
Some("Runs the selected action."),
);
let request = approval_prompt_request(
"custom_server",
"run_action",
Some(serde_json::json!({"id": 1})),
Some(&metadata),
);
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
Some(&approval_metadata(
/*connector_id*/ None,
/*connector_name*/ None,
/*connector_description*/ None,
Some("Run Action"),
Some("Runs the selected action."),
)),
&request,
Some(&serde_json::json!({"id": 1})),
/*tool_params_display*/ None,
prompt_options(
@@ -1145,8 +1299,9 @@ fn guardian_mcp_review_request_includes_invocation_metadata() {
})),
};
let request = build_guardian_mcp_tool_review_request(
let request = build_mcp_tool_approval_request(
"call-1",
"browser_navigate",
&invocation,
Some(&approval_metadata(
Some("playwright"),
@@ -1159,10 +1314,11 @@ fn guardian_mcp_review_request_includes_invocation_metadata() {
assert_eq!(
request,
GuardianApprovalRequest::McpToolCall {
ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
id: "call-1".to_string(),
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "browser_navigate".to_string(),
hook_tool_name: "browser_navigate".to_string(),
arguments: Some(serde_json::json!({
"url": "https://example.com",
})),
@@ -1172,7 +1328,7 @@ fn guardian_mcp_review_request_includes_invocation_metadata() {
tool_title: Some("Navigate".to_string()),
tool_description: Some("Open a page".to_string()),
annotations: None,
}
})
);
}
@@ -1195,14 +1351,16 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
openai_file_input_params: None,
};
let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata));
let request =
build_mcp_tool_approval_request("call-1", "dangerous_tool", &invocation, Some(&metadata));
assert_eq!(
request,
GuardianApprovalRequest::McpToolCall {
ApprovalRequest::McpToolCall(McpToolCallApprovalRequest {
id: "call-1".to_string(),
server: "custom_server".to_string(),
tool_name: "dangerous_tool".to_string(),
hook_tool_name: "dangerous_tool".to_string(),
arguments: None,
connector_id: None,
connector_name: None,
@@ -1214,7 +1372,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
open_world_hint: Some(true),
read_only_hint: Some(false),
}),
}
})
);
}
@@ -1316,16 +1474,24 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
#[test]
fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
let metadata = approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
);
let request = approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some(serde_json::json!({
"calendar_id": "primary",
})),
Some(&metadata),
);
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
)),
&request,
Some(&serde_json::json!({
"calendar_id": "primary",
})),
@@ -1351,16 +1517,24 @@ fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
#[test]
fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() {
let metadata = approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
);
let request = approval_prompt_request(
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some(serde_json::json!({
"calendar_id": "primary",
})),
Some(&metadata),
);
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
)),
&request,
Some(&serde_json::json!({
"calendar_id": "primary",
})),

View File

@@ -14,6 +14,9 @@ use crate::agent::Mailbox;
use crate::agent::MailboxReceiver;
use crate::agent::agent_status_from_event;
use crate::agent::status::is_final;
use crate::approval_request::ApplyPatchApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::RequestPermissionsApprovalRequest;
use crate::build_available_skills;
use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
@@ -99,7 +102,6 @@ use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ItemCompletedEvent;
@@ -116,7 +118,6 @@ use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
@@ -126,7 +127,6 @@ use codex_rollout_trace::AgentResultTracePayload;
use codex_rollout_trace::ThreadStartedTraceMetadata;
use codex_rollout_trace::ThreadTraceContext;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::parse_command;
use codex_terminal_detection::user_agent;
use codex_thread_store::CreateThreadParams;
use codex_thread_store::LiveThread;
@@ -1844,26 +1844,52 @@ impl Session {
/// be used to derive the available decisions via
/// [ExecApprovalRequestEvent::default_available_decisions].
#[allow(clippy::too_many_arguments)]
pub async fn request_command_approval_for_request(
&self,
turn_context: &TurnContext,
approval_request: CommandApprovalRequest,
approval_id: Option<String>,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
available_decisions: Option<Vec<ReviewDecision>>,
) -> ReviewDecision {
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
vec![
NetworkPolicyAmendment {
host: context.host.clone(),
action: NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: context.host.clone(),
action: NetworkPolicyRuleAction::Deny,
},
]
});
let event = approval_request.exec_approval_event(
turn_context.sub_id.clone(),
approval_id,
reason,
network_approval_context,
proposed_execpolicy_amendment,
proposed_network_policy_amendments,
available_decisions,
);
self.request_exec_approval_event(turn_context, event).await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_command_approval(
async fn request_exec_approval_event(
&self,
turn_context: &TurnContext,
call_id: String,
approval_id: Option<String>,
command: Vec<String>,
cwd: AbsolutePathBuf,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
additional_permissions: Option<AdditionalPermissionProfile>,
available_decisions: Option<Vec<ReviewDecision>>,
event: ExecApprovalRequestEvent,
) -> ReviewDecision {
// command-level approvals use `call_id`.
// `approval_id` is only present for subcommand callbacks (execve intercept)
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
let effective_approval_id = event.effective_approval_id();
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let prev_entry = {
@@ -1880,60 +1906,53 @@ impl Session {
warn!("Overwriting existing pending approval for call_id: {effective_approval_id}");
}
let parsed_cmd = parse_command(&command);
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
vec![
NetworkPolicyAmendment {
host: context.host.clone(),
action: NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: context.host.clone(),
action: NetworkPolicyRuleAction::Deny,
},
]
});
let available_decisions = available_decisions.unwrap_or_else(|| {
let available_decisions = event.available_decisions.clone().unwrap_or_else(|| {
ExecApprovalRequestEvent::default_available_decisions(
network_approval_context.as_ref(),
proposed_execpolicy_amendment.as_ref(),
proposed_network_policy_amendments.as_deref(),
additional_permissions.as_ref(),
event.network_approval_context.as_ref(),
event.proposed_execpolicy_amendment.as_ref(),
event.proposed_network_policy_amendments.as_deref(),
event.additional_permissions.as_ref(),
)
});
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
approval_id,
turn_id: turn_context.sub_id.clone(),
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
proposed_network_policy_amendments,
additional_permissions,
available_decisions: Some(available_decisions),
parsed_cmd,
});
self.send_event(turn_context, event).await;
self.send_event(
turn_context,
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
available_decisions: Some(available_decisions),
..event
}),
)
.await;
rx_approve.await.unwrap_or(ReviewDecision::Abort)
}
pub async fn request_patch_approval_for_request(
&self,
turn_context: &TurnContext,
approval_request: ApplyPatchApprovalRequest,
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
let event = approval_request.apply_patch_approval_event(
turn_context.sub_id.clone(),
reason,
grant_root,
);
self.request_apply_patch_approval_event(turn_context, event)
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_patch_approval(
async fn request_apply_patch_approval_event(
&self,
turn_context: &TurnContext,
call_id: String,
changes: HashMap<PathBuf, FileChange>,
reason: Option<String>,
grant_root: Option<PathBuf>,
event: ApplyPatchApprovalRequestEvent,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let approval_id = call_id.clone();
let approval_id = event.call_id.clone();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -1948,14 +1967,8 @@ impl Session {
warn!("Overwriting existing pending approval for call_id: {approval_id}");
}
let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
turn_id: turn_context.sub_id.clone(),
changes,
reason,
grant_root,
});
self.send_event(turn_context, event).await;
self.send_event(turn_context, EventMsg::ApplyPatchApprovalRequest(event))
.await;
rx_approve
}
@@ -2012,6 +2025,13 @@ impl Session {
}
let requested_permissions = args.permissions;
let approval_request = RequestPermissionsApprovalRequest {
id: call_id.clone(),
turn_id: turn_context.sub_id.clone(),
reason: args.reason.clone(),
permissions: requested_permissions.clone(),
cwd: cwd.clone(),
};
if crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) {
let originating_turn_state = {
@@ -2021,17 +2041,11 @@ impl Session {
let review_id = crate::guardian::new_guardian_review_id();
let session = Arc::clone(self);
let turn = Arc::clone(turn_context);
let request = crate::guardian::GuardianApprovalRequest::RequestPermissions {
id: call_id,
turn_id: turn_context.sub_id.clone(),
reason: args.reason,
permissions: requested_permissions.clone(),
};
let review_rx = crate::guardian::spawn_approval_request_review(
session,
turn,
review_id,
request,
approval_request.clone().into(),
/*retry_reason*/ None,
codex_analytics::GuardianApprovalRequestSource::MainTurn,
cancellation_token.clone(),
@@ -2111,14 +2125,9 @@ impl Session {
warn!("Overwriting existing pending request_permissions for call_id: {call_id}");
}
let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
call_id: call_id.clone(),
turn_id: turn_context.sub_id.clone(),
reason: args.reason,
permissions: requested_permissions,
cwd: Some(cwd),
});
self.send_event(turn_context.as_ref(), event).await;
let event = approval_request.request_permissions_event();
self.send_event(turn_context.as_ref(), EventMsg::RequestPermissions(event))
.await;
tokio::select! {
biased;
_ = cancellation_token.cancelled() => {

View File

@@ -1,5 +1,5 @@
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::GuardianNetworkAccessTrigger;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
@@ -8,7 +8,6 @@ use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::network_policy_decision::denied_network_policy_message;
use crate::session::session::Session;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ToolError;
use codex_hooks::PermissionRequestDecision;
use codex_network_proxy::BlockedRequest;
@@ -460,17 +459,28 @@ impl NetworkApprovalService {
protocol,
};
let guardian_approval_id = Self::approval_id_for_key(&key);
let prompt_command = vec!["network-access".to_string(), target.clone()];
let command = owner_call
.as_ref()
.map_or_else(|| prompt_command.join(" "), |call| call.command.clone());
if let Some(permission_request_decision) = run_permission_request_hooks(
&session,
&turn_context,
&guardian_approval_id,
PermissionRequestPayload::bash(command, Some(format!("network-access {target}"))),
)
.await
let command = owner_call.as_ref().map_or_else(
|| format!("network-access {target}"),
|call| call.command.clone(),
);
let approval_request = CommandApprovalRequest::NetworkAccess {
id: guardian_approval_id.clone(),
turn_id: owner_call
.as_ref()
.map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),
target: target.clone(),
hook_command: command,
cwd: turn_context.cwd.clone(),
host: request.host.clone(),
protocol,
port: key.port,
trigger: owner_call.as_ref().map(|call| call.trigger.clone()),
};
if let Some(permission_request_decision) =
run_permission_request_hooks(&session, &turn_context, &guardian_approval_id, {
approval_request.permission_request_payload()
})
.await
{
match permission_request_decision {
PermissionRequestDecision::Allow => {
@@ -503,33 +513,20 @@ impl NetworkApprovalService {
&session,
&turn_context,
review_id,
GuardianApprovalRequest::NetworkAccess {
id: guardian_approval_id.clone(),
turn_id: owner_call
.as_ref()
.map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),
target,
host: request.host,
protocol,
port: key.port,
trigger: owner_call.as_ref().map(|call| call.trigger.clone()),
},
approval_request.clone().into(),
Some(policy_denial_message.clone()),
)
.await
} else {
let available_decisions = None;
session
.request_command_approval(
.request_command_approval_for_request(
turn_context.as_ref(),
guardian_approval_id,
approval_request,
/*approval_id*/ None,
prompt_command,
turn_context.cwd.clone(),
Some(prompt_reason),
Some(network_approval_context.clone()),
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
/*additional_permissions*/ None,
available_decisions,
)
.await

View File

@@ -394,8 +394,11 @@ impl ToolOrchestrator {
where
T: ToolRuntime<Rq, Out>,
{
let approval_request = tool.approval_request(req, &approval_ctx);
if evaluate_permission_request_hooks
&& let Some(permission_request) = tool.permission_request_payload(req)
&& let Some(permission_request) = approval_request
.as_ref()
.and_then(crate::approval_request::ApprovalRequest::permission_request_payload)
{
match run_permission_request_hooks(
approval_ctx.session,

View File

@@ -3,14 +3,13 @@
//! Assumes `apply_patch` verification/approval happened upstream. Reuses the
//! selected turn environment filesystem for both local and remote turns, with
//! sandboxing enforced by the explicit filesystem sandbox context.
use crate::approval_request::ApplyPatchApprovalRequest;
use crate::approval_request::ApprovalRequest;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
@@ -53,14 +52,12 @@ impl ApplyPatchRuntime {
Self
}
fn build_guardian_review_request(
req: &ApplyPatchRequest,
call_id: &str,
) -> GuardianApprovalRequest {
GuardianApprovalRequest::ApplyPatch {
fn build_approval_request(req: &ApplyPatchRequest, call_id: &str) -> ApplyPatchApprovalRequest {
ApplyPatchApprovalRequest {
id: call_id.to_string(),
cwd: req.action.cwd.clone(),
files: req.file_paths.clone(),
changes: req.changes.clone(),
patch: req.action.patch.clone(),
}
}
@@ -108,26 +105,29 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
) -> BoxFuture<'a, ReviewDecision> {
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let retry_reason = ctx.retry_reason.clone();
let approval_keys = self.approval_keys(req);
let changes = req.changes.clone();
let guardian_review_id = ctx.guardian_review_id.clone();
let approval_request = Self::build_approval_request(req, ctx.call_id);
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
return review_approval_request(
session,
turn,
review_id,
approval_request.clone().into(),
retry_reason,
)
.await;
}
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
if let Some(reason) = retry_reason {
let rx_approve = session
.request_patch_approval(
.request_patch_approval_for_request(
turn,
call_id,
changes.clone(),
approval_request.clone(),
Some(reason),
/*grant_root*/ None,
)
@@ -141,8 +141,11 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
approval_keys,
|| async move {
let rx_approve = session
.request_patch_approval(
turn, call_id, changes, /*reason*/ None, /*grant_root*/ None,
.request_patch_approval_for_request(
turn,
approval_request.clone(),
/*reason*/ None,
/*grant_root*/ None,
)
.await;
rx_approve.await.unwrap_or_default()
@@ -173,14 +176,12 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(
fn approval_request(
&self,
req: &ApplyPatchRequest,
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload {
tool_name: HookToolName::apply_patch(),
tool_input: serde_json::json!({ "command": req.action.patch }),
})
ctx: &ApprovalCtx<'_>,
) -> Option<ApprovalRequest> {
Some(Self::build_approval_request(req, ctx.call_id).into())
}
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::approval_request::ApplyPatchApprovalRequest;
use crate::tools::sandboxing::SandboxAttempt;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -65,14 +66,15 @@ fn guardian_review_request_includes_patch_context() {
permissions_preapproved: false,
};
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1");
let guardian_request = ApplyPatchRuntime::build_approval_request(&request, "call-1");
assert_eq!(
guardian_request,
GuardianApprovalRequest::ApplyPatch {
ApplyPatchApprovalRequest {
id: "call-1".to_string(),
cwd: expected_cwd,
files: request.file_paths,
changes: request.changes,
patch: expected_patch,
}
);
@@ -80,7 +82,6 @@ fn guardian_review_request_includes_patch_context() {
#[test]
fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
let runtime = ApplyPatchRuntime::new();
let path = std::env::temp_dir()
.join("apply-patch-permission-request-payload.txt")
.abs();
@@ -98,9 +99,8 @@ fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
permissions_preapproved: false,
};
let payload = runtime
.permission_request_payload(&req)
.expect("permission request payload");
let payload =
ApplyPatchRuntime::build_approval_request(&req, "call-1").permission_request_payload();
assert_eq!(payload.tool_name.name(), "apply_patch");
assert_eq!(

View File

@@ -8,10 +8,11 @@ builds sandbox transform inputs, and runs them under the current SandboxAttempt.
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
use crate::approval_request::ApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::GuardianNetworkAccessTrigger;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
@@ -25,7 +26,6 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
@@ -120,6 +120,18 @@ impl ShellRuntime {
tx_event: ctx.session.get_tx_event(),
})
}
fn build_approval_request(req: &ShellRequest, call_id: String) -> CommandApprovalRequest {
CommandApprovalRequest::Shell {
id: call_id,
command: req.command.clone(),
hook_command: req.hook_command.clone(),
cwd: req.cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
}
}
}
impl Sandboxable for ShellRuntime {
@@ -149,13 +161,11 @@ impl Approvable<ShellRequest> for ShellRuntime {
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
let keys = self.approval_keys(req);
let command = req.command.clone();
let cwd = req.cwd.clone();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let approval_request = Self::build_approval_request(req, ctx.call_id.to_string());
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
@@ -163,14 +173,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
session,
turn,
review_id,
GuardianApprovalRequest::Shell {
id: call_id,
command,
cwd: cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
},
approval_request.clone().into(),
retry_reason,
)
.await;
@@ -178,18 +181,15 @@ impl Approvable<ShellRequest> for ShellRuntime {
with_cached_approval(&session.services, "shell", keys, move || async move {
let available_decisions = None;
session
.request_command_approval(
.request_command_approval_for_request(
turn,
call_id,
approval_request.clone(),
/*approval_id*/ None,
command,
cwd,
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.additional_permissions.clone(),
available_decisions,
)
.await
@@ -202,11 +202,12 @@ impl Approvable<ShellRequest> for ShellRuntime {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(&self, req: &ShellRequest) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.justification.clone(),
))
fn approval_request(
&self,
req: &ShellRequest,
ctx: &ApprovalCtx<'_>,
) -> Option<ApprovalRequest> {
Some(Self::build_approval_request(req, ctx.call_id.to_string()).into())
}
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {

View File

@@ -1,9 +1,9 @@
use super::ShellRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::cancel_when_either;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
@@ -16,7 +16,6 @@ use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
@@ -396,7 +395,6 @@ impl CoreShellActionProvider {
stopwatch: &Stopwatch,
additional_permissions: Option<AdditionalPermissionProfile>,
) -> anyhow::Result<PromptDecision> {
let command = join_program_and_argv(program, argv);
let workdir = workdir.clone();
let session = self.session.clone();
let turn = self.turn.clone();
@@ -407,16 +405,20 @@ impl CoreShellActionProvider {
Ok(stopwatch
.pause_for(async move {
// 1) Run PermissionRequest hooks
let permission_request = PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(&command),
/*description*/ None,
);
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
let approval_request = CommandApprovalRequest::Execve {
id: call_id.clone(),
source,
program: program.to_string_lossy().into_owned(),
argv: argv.to_vec(),
cwd: workdir.clone(),
additional_permissions: additional_permissions.clone(),
};
match run_permission_request_hooks(
&session,
&turn,
&effective_approval_id,
permission_request,
approval_request.permission_request_payload(),
)
.await
{
@@ -443,14 +445,7 @@ impl CoreShellActionProvider {
&session,
&turn,
review_id.clone(),
GuardianApprovalRequest::Execve {
id: call_id.clone(),
source,
program: program.to_string_lossy().into_owned(),
argv: argv.to_vec(),
cwd: workdir.clone(),
additional_permissions,
},
approval_request.clone().into(),
/*retry_reason*/ None,
)
.await;
@@ -463,16 +458,13 @@ impl CoreShellActionProvider {
// 3) Fall back to regular user prompt
let decision = session
.request_command_approval(
.request_command_approval_for_request(
&turn,
call_id,
approval_request,
approval_id,
command,
workdir.clone(),
/*reason*/ None,
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
additional_permissions,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
)
.await;

View File

@@ -4,11 +4,12 @@ Runtime: unified exec
Handles approval + sandbox orchestration for unified exec requests, delegating to
the process manager to spawn PTYs once an ExecRequest is prepared.
*/
use crate::approval_request::ApprovalRequest;
use crate::approval_request::CommandApprovalRequest;
use crate::approval_request::GuardianNetworkAccessTrigger;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
@@ -23,7 +24,6 @@ use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
@@ -110,6 +110,19 @@ impl<'a> UnifiedExecRuntime<'a> {
shell_mode,
}
}
fn build_approval_request(req: &UnifiedExecRequest, call_id: String) -> CommandApprovalRequest {
CommandApprovalRequest::ExecCommand {
id: call_id,
command: req.command.clone(),
hook_command: req.hook_command.clone(),
cwd: req.cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
}
}
}
impl Sandboxable for UnifiedExecRuntime<'_> {
@@ -143,11 +156,9 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let keys = self.approval_keys(req);
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let command = req.command.clone();
let cwd = req.cwd.clone();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let approval_request = Self::build_approval_request(req, ctx.call_id.to_string());
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
@@ -155,15 +166,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
session,
turn,
review_id,
GuardianApprovalRequest::ExecCommand {
id: call_id,
command,
cwd: cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
},
approval_request.clone().into(),
retry_reason,
)
.await;
@@ -171,18 +174,15 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
with_cached_approval(&session.services, "unified_exec", keys, || async move {
let available_decisions = None;
session
.request_command_approval(
.request_command_approval_for_request(
turn,
call_id,
approval_request.clone(),
/*approval_id*/ None,
command,
cwd.clone(),
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.additional_permissions.clone(),
available_decisions,
)
.await
@@ -198,14 +198,12 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(
fn approval_request(
&self,
req: &UnifiedExecRequest,
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.justification.clone(),
))
ctx: &ApprovalCtx<'_>,
) -> Option<ApprovalRequest> {
Some(Self::build_approval_request(req, ctx.call_id.to_string()).into())
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {

View File

@@ -4,6 +4,7 @@
//! `ApprovalCtx`, `Approvable`) together with the sandbox orchestration traits
//! and helpers (`Sandboxable`, `ToolRuntime`, `SandboxAttempt`, etc.).
use crate::approval_request::ApprovalRequest;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
@@ -309,9 +310,9 @@ pub(crate) trait Approvable<Req> {
None
}
/// Return hook input for approval-time policy hooks when this runtime wants
/// hook evaluation to run before guardian or user approval.
fn permission_request_payload(&self, _req: &Req) -> Option<PermissionRequestPayload> {
/// Return the canonical approval action for this request when the runtime
/// participates in approval hooks and/or guardian review.
fn approval_request(&self, _req: &Req, _ctx: &ApprovalCtx<'_>) -> Option<ApprovalRequest> {
None
}