diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bed54ea512..692ed5ad12 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2596,6 +2596,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-git-utils", + "codex-guardian", "codex-home", "codex-hooks", "codex-image-generation-extension", @@ -3071,8 +3072,14 @@ dependencies = [ name = "codex-guardian" version = "0.0.0" dependencies = [ + "codex-analytics", "codex-extension-api", "codex-protocol", + "codex-shell-command", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "serde", + "serde_json", ] [[package]] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d21245c63e..8ef421b357 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -46,6 +46,7 @@ codex-models-manager = { workspace = true } codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } +codex-guardian = { workspace = true } codex-hooks = { workspace = true } codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 11e90b6b67..e8dee85c2b 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -1,455 +1,7 @@ -use std::path::Path; - -use codex_analytics::GuardianReviewedAction; -use codex_extension_api::ApprovalReviewMcpAnnotations as GuardianMcpAnnotations; -use codex_extension_api::ApprovalReviewNetworkAccessTrigger as GuardianNetworkAccessTrigger; -use codex_extension_api::ApprovalReviewRequest as GuardianApprovalRequest; -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(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, -} - -#[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 { - 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, -) -> serde_json::Result { - 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::>(); - (Value::Array(values), truncated) - } - Value::Object(values) => { - let mut entries = values.into_iter().collect::>(); - 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 { - 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 { - 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, - }) -} +pub(crate) use codex_guardian::format_guardian_action_pretty; +#[cfg(test)] +pub(crate) use codex_guardian::guardian_approval_request_to_json; +pub(crate) use codex_guardian::guardian_assessment_action; +pub(crate) use codex_guardian::guardian_request_target_item_id; +pub(crate) use codex_guardian::guardian_request_turn_id; +pub(crate) use codex_guardian::guardian_reviewed_action; diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f444327ee8..1896caa6ad 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -60,9 +60,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; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; -const TRUNCATION_TAG: &str = "truncated"; /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 008e979574..11c1f5a3a6 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -11,9 +11,7 @@ use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use codex_utils_output_truncation::approx_bytes_for_tokens; use codex_utils_output_truncation::approx_token_count; -use codex_utils_output_truncation::approx_tokens_from_byte_count; use super::AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX; use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS; @@ -23,8 +21,8 @@ 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; +pub(crate) use codex_guardian::guardian_truncate_text; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -513,68 +511,6 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String, bool) { - if content.is_empty() { - return (String::new(), false); - } - - let max_bytes = approx_bytes_for_tokens(token_cap); - if content.len() <= max_bytes { - return (content.to_string(), false); - } - - let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); - let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); - if max_bytes <= marker.len() { - return (marker, true); - } - - let available_bytes = max_bytes.saturating_sub(marker.len()); - let prefix_budget = available_bytes / 2; - let suffix_budget = available_bytes.saturating_sub(prefix_budget); - let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - - (format!("{prefix}{marker}{suffix}"), true) -} - -fn split_guardian_truncation_bounds( - content: &str, - prefix_bytes: usize, - suffix_bytes: usize, -) -> (&str, &str) { - if content.is_empty() { - return ("", ""); - } - - let len = content.len(); - let suffix_start_target = len.saturating_sub(suffix_bytes); - let mut prefix_end = 0usize; - let mut suffix_start = len; - let mut suffix_started = false; - - for (index, ch) in content.char_indices() { - let char_end = index + ch.len_utf8(); - if char_end <= prefix_bytes { - prefix_end = char_end; - continue; - } - - if index >= suffix_start_target { - if !suffix_started { - suffix_start = index; - suffix_started = true; - } - continue; - } - } - - if suffix_start < prefix_end { - suffix_start = prefix_end; - } - - (&content[..prefix_end], &content[suffix_start..]) -} - /// The model is asked for strict JSON, but we still accept a surrounding prose /// wrapper so transient formatting drift fails less noisily during dogfooding. /// Non-JSON output is still a review failure; this is only a thin recovery path diff --git a/codex-rs/ext/guardian/Cargo.toml b/codex-rs/ext/guardian/Cargo.toml index 2020ec7551..460c5ecd96 100644 --- a/codex-rs/ext/guardian/Cargo.toml +++ b/codex-rs/ext/guardian/Cargo.toml @@ -14,5 +14,11 @@ doctest = false workspace = true [dependencies] +codex-analytics = { workspace = true } codex-extension-api = { workspace = true } codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/codex-rs/ext/guardian/src/approval_request.rs b/codex-rs/ext/guardian/src/approval_request.rs new file mode 100644 index 0000000000..7b44cbb5a3 --- /dev/null +++ b/codex-rs/ext/guardian/src/approval_request.rs @@ -0,0 +1,453 @@ +use std::path::Path; + +use codex_analytics::GuardianReviewedAction; +use codex_extension_api::ApprovalReviewMcpAnnotations as GuardianMcpAnnotations; +use codex_extension_api::ApprovalReviewNetworkAccessTrigger as GuardianNetworkAccessTrigger; +use codex_extension_api::ApprovalReviewRequest as GuardianApprovalRequest; +use codex_protocol::approvals::GuardianAssessmentAction; +use codex_protocol::approvals::GuardianCommandSource; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::SandboxPermissions; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Serialize; +use serde_json::Value; + +use super::guardian_truncate_text; + +const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; + +#[derive(Serialize)] +struct CommandApprovalAction<'a> { + tool: &'a str, + command: &'a [String], + cwd: &'a Path, + sandbox_permissions: 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, +} + +#[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 { + serde_json::to_value(value) +} + +fn serialize_command_guardian_action( + tool: &'static str, + command: &[String], + cwd: &Path, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option<&AdditionalPermissionProfile>, + justification: Option<&String>, + tty: Option, +) -> serde_json::Result { + 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::>(); + (Value::Array(values), truncated) + } + Value::Object(values) => { + let mut entries = values.into_iter().collect::>(); + 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 struct FormattedGuardianAction { + pub text: String, + pub truncated: bool, +} + +pub fn guardian_approval_request_to_json( + action: &GuardianApprovalRequest, +) -> serde_json::Result { + 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 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 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 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 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 fn format_guardian_action_pretty( + action: &GuardianApprovalRequest, +) -> serde_json::Result { + 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, + }) +} diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs index 9866888edb..c51bdc9532 100644 --- a/codex-rs/ext/guardian/src/lib.rs +++ b/codex-rs/ext/guardian/src/lib.rs @@ -13,6 +13,18 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; +mod approval_request; +mod truncation; + +pub use approval_request::FormattedGuardianAction; +pub use approval_request::format_guardian_action_pretty; +pub use approval_request::guardian_approval_request_to_json; +pub use approval_request::guardian_assessment_action; +pub use approval_request::guardian_request_target_item_id; +pub use approval_request::guardian_request_turn_id; +pub use approval_request::guardian_reviewed_action; +pub use truncation::guardian_truncate_text; + /// Guardian extension dependencies supplied by the host at construction time. #[derive(Clone, Debug)] pub struct GuardianExtension { diff --git a/codex-rs/ext/guardian/src/truncation.rs b/codex-rs/ext/guardian/src/truncation.rs new file mode 100644 index 0000000000..a53225e3f0 --- /dev/null +++ b/codex-rs/ext/guardian/src/truncation.rs @@ -0,0 +1,67 @@ +use codex_utils_output_truncation::approx_bytes_for_tokens; +use codex_utils_output_truncation::approx_tokens_from_byte_count; + +const TRUNCATION_TAG: &str = "truncated"; + +/// Truncates text to an approximate token cap while retaining its prefix and suffix. +pub fn guardian_truncate_text(content: &str, token_cap: usize) -> (String, bool) { + if content.is_empty() { + return (String::new(), false); + } + + let max_bytes = approx_bytes_for_tokens(token_cap); + if content.len() <= max_bytes { + return (content.to_string(), false); + } + + let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); + let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); + if max_bytes <= marker.len() { + return (marker, true); + } + + let available_bytes = max_bytes.saturating_sub(marker.len()); + let prefix_budget = available_bytes / 2; + let suffix_budget = available_bytes.saturating_sub(prefix_budget); + let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); + + (format!("{prefix}{marker}{suffix}"), true) +} + +fn split_guardian_truncation_bounds( + content: &str, + prefix_bytes: usize, + suffix_bytes: usize, +) -> (&str, &str) { + if content.is_empty() { + return ("", ""); + } + + let len = content.len(); + let suffix_start_target = len.saturating_sub(suffix_bytes); + let mut prefix_end = 0usize; + let mut suffix_start = len; + let mut suffix_started = false; + + for (index, ch) in content.char_indices() { + let char_end = index + ch.len_utf8(); + if char_end <= prefix_bytes { + prefix_end = char_end; + continue; + } + + if index >= suffix_start_target { + if !suffix_started { + suffix_start = index; + suffix_started = true; + } + continue; + } + } + + if suffix_start < prefix_end { + suffix_start = prefix_end; + } + + (&content[..prefix_end], &content[suffix_start..]) +}