Skip to content
50 changes: 41 additions & 9 deletions codex-rs/core/src/guardian/approval_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub(crate) enum GuardianApprovalRequest {
host: String,
protocol: NetworkApprovalProtocol,
port: u16,
trigger: Option<GuardianNetworkAccessTrigger>,
},
McpToolCall {
id: String,
Expand All @@ -75,6 +76,22 @@ pub(crate) enum GuardianApprovalRequest {
},
}

#[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<PermissionProfile>,
#[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")]
Expand Down Expand Up @@ -131,6 +148,18 @@ struct McpToolCallApprovalAction<'a> {
annotations: Option<&'a GuardianMcpAnnotations>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkAccessApprovalAction<'a> {
Comment thread
viyatb-oai marked this conversation as resolved.
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,
Expand Down Expand Up @@ -297,13 +326,15 @@ pub(crate) fn guardian_approval_request_to_json(
host,
protocol,
port,
} => Ok(serde_json::json!({
"tool": "network_access",
"target": target,
"host": host,
"protocol": protocol,
"port": port,
})),
trigger,
} => serialize_guardian_action(NetworkAccessApprovalAction {
tool: "network_access",
target,
host,
protocol: *protocol,
port: *port,
trigger: trigger.as_ref(),
}),
GuardianApprovalRequest::McpToolCall {
id: _,
server,
Expand Down Expand Up @@ -371,12 +402,13 @@ pub(crate) fn guardian_assessment_action(
}
}
GuardianApprovalRequest::NetworkAccess {
id: _,
turn_id: _,
id: _id,
turn_id: _turn_id,
target,
host,
protocol,
port,
trigger: _trigger,
} => GuardianAssessmentAction::NetworkAccess {
target: target.clone(),
host: host.clone(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/guardian/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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 review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
Expand Down
70 changes: 70 additions & 0 deletions codex-rs/core/src/guardian/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
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;
Expand Down Expand Up @@ -659,6 +660,50 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json
Ok(())
}

#[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 {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: Some(GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: cwd.clone(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Fetch the release metadata.".to_string()),
tty: None,
}),
};

assert_eq!(
guardian_approval_request_to_json(&action)?,
serde_json::json!({
"tool": "network_access",
"target": "https://example.com:443",
"host": "example.com",
"protocol": "https",
"port": 443,
"trigger": {
"callId": "call-1",
"toolName": "shell",
"command": ["curl", "https://example.com"],
"cwd": cwd.to_string_lossy().to_string(),
"sandboxPermissions": "use_default",
"justification": "Fetch the release metadata.",
},
})
);

Ok(())
}

#[test]
fn guardian_assessment_action_redacts_apply_patch_patch_text() {
let cwd = test_path_buf("/tmp").abs();
Expand Down Expand Up @@ -690,6 +735,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: None,
};
let apply_patch = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
Expand All @@ -709,6 +755,30 @@ 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 {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: Some(GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: None,
}),
};

assert_eq!(guardian_request_target_item_id(&network_access), None);
}

#[tokio::test]
async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
Expand Down
33 changes: 27 additions & 6 deletions codex-rs/core/src/tools/network_approval.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
Expand Down Expand Up @@ -47,6 +48,7 @@ pub(crate) enum NetworkApprovalMode {
pub(crate) struct NetworkApprovalSpec {
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
pub trigger: GuardianNetworkAccessTrigger,
pub command: String,
}

Expand Down Expand Up @@ -177,6 +179,7 @@ impl PendingHostApproval {
struct ActiveNetworkApprovalCall {
registration_id: String,
turn_id: String,
trigger: GuardianNetworkAccessTrigger,
command: String,
}

Expand Down Expand Up @@ -210,14 +213,21 @@ impl NetworkApprovalService {
other_approved_hosts.extend(approved_hosts.iter().cloned());
}

async fn register_call(&self, registration_id: String, turn_id: String, command: String) {
async fn register_call(
&self,
registration_id: String,
turn_id: String,
trigger: GuardianNetworkAccessTrigger,
command: String,
) {
let mut active_calls = self.active_calls.lock().await;
let key = registration_id.clone();
active_calls.insert(
key,
Arc::new(ActiveNetworkApprovalCall {
registration_id,
turn_id,
trigger,
command,
}),
);
Expand Down Expand Up @@ -369,11 +379,11 @@ impl NetworkApprovalService {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}

let owner_call = self.resolve_single_active_call().await;
let network_approval_context = NetworkApprovalContext {
host: request.host.clone(),
protocol,
};
let owner_call = self.resolve_single_active_call().await;
let guardian_approval_id = Self::approval_id_for_key(&key);
let prompt_command = vec!["network-access".to_string(), target.clone()];
let command = owner_call
Expand Down Expand Up @@ -431,6 +441,7 @@ impl NetworkApprovalService {
host: request.host,
protocol,
port: key.port,
trigger: owner_call.as_ref().map(|call| call.trigger.clone()),
},
Some(policy_denial_message.clone()),
)
Expand Down Expand Up @@ -623,21 +634,31 @@ pub(crate) async fn begin_network_approval(
managed_network_active: bool,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
let spec = spec?;
if !managed_network_active || spec.network.is_none() {
let NetworkApprovalSpec {
network,
mode,
trigger,
command,
} = spec?;
if !managed_network_active || network.is_none() {
return None;
}

let registration_id = Uuid::new_v4().to_string();
session
.services
.network_approval
.register_call(registration_id.clone(), turn_id.to_string(), spec.command)
.register_call(
registration_id.clone(),
turn_id.to_string(),
trigger,
command,
)
.await;

Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
mode: spec.mode,
mode,
})
}

Expand Down
Loading
Loading