Compare commits

...

8 Commits

Author SHA1 Message Date
Charles Cunningham
949ad9ddfb core: update guardian network test fixture
Add the new parent tool item id field to the remaining network access guardian test fixture on the core-only branch.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:58:38 -07:00
Charles Cunningham
3cee35bfef network-proxy: preserve parent item id on mitm block
Propagate the extracted parent tool item id through the CONNECT limited-mode MITM-required block path so core can attribute the denial to the correct active tool call. Also derive PartialEq/Eq for BlockedRequest to keep the focused regression test as a full-object assertion.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:57:48 -07:00
Charles Cunningham
b45bbe8278 core: remove unused network approval helper
Drop the unused protocol-formatting helper from the core-only branch so clippy does not fail on dead code after the PR split.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:41:28 -07:00
Charles Cunningham
13489789bf core: ignore network parent id in guardian action json
Keep the core-only PR focused by explicitly ignoring the new network approval parent field in the existing guardian action JSON pattern matches.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:35:29 -07:00
Charles Cunningham
28014e514c core: fix split network approval plumbing
Restore the parent tool item id field and helper on guardian network approvals, and wire the begin_network_approval callsite to pass the tool call id after splitting the work into stacked branches.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:25:20 -07:00
Charles Cunningham
1a0c7660b7 network-proxy: reduce proxy disabled args
Bundle proxy-disabled response parameters into a single typed args struct so the callsites stay readable and clippy stops flagging the helper for too many arguments.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:13:15 -07:00
Charles Cunningham
f891b16475 core: carry parent tool item id for network approvals
Remove the synthetic network approval token and carry parent_tool_item_id directly through the managed proxy attribution path.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:13:15 -07:00
Charles Cunningham
b84b970653 core: attribute network approvals to owning tool calls
Thread a stable network owner id through managed proxy credentials and use it to resolve the exact active tool call for blocked network requests. This makes concurrent network approvals attribute deterministically and preserves parent tool item ids downstream.

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:12:06 -07:00
34 changed files with 446 additions and 102 deletions

View File

@@ -1693,6 +1693,7 @@ impl CodexMessageProcessor {
network: started_network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy),
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
windows_sandbox_private_desktop: self

View File

@@ -732,6 +732,7 @@ mod tests {
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
expiration: ExecExpiration::DefaultTimeout,
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::WindowsRestrictedToken,
@@ -845,6 +846,7 @@ mod tests {
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
expiration: ExecExpiration::Cancellation(CancellationToken::new()),
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::None,

View File

@@ -5033,6 +5033,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
@@ -5051,6 +5052,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
.config

View File

@@ -128,6 +128,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context

View File

@@ -81,6 +81,9 @@ pub struct ExecParams {
pub capture_policy: ExecCapturePolicy,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
/// Parent tool item id to propagate through managed proxy settings so
/// blocked requests can be attributed back to the originating tool call.
pub parent_tool_item_id: Option<String>,
pub sandbox_permissions: SandboxPermissions,
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub windows_sandbox_private_desktop: bool,
@@ -263,6 +266,7 @@ pub fn build_exec_request(
expiration,
capture_policy,
network,
parent_tool_item_id,
sandbox_permissions,
windows_sandbox_level,
windows_sandbox_private_desktop,
@@ -270,7 +274,7 @@ pub fn build_exec_request(
arg0: _,
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_parent_tool_item(&mut env, parent_tool_item_id.as_deref());
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -301,6 +305,7 @@ pub fn build_exec_request(
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
parent_tool_item_id: parent_tool_item_id.as_deref(),
sandbox_policy_cwd: sandbox_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
@@ -324,6 +329,7 @@ pub(crate) async fn execute_exec_request(
cwd,
env,
network,
parent_tool_item_id,
expiration,
capture_policy,
sandbox,
@@ -345,6 +351,7 @@ pub(crate) async fn execute_exec_request(
capture_policy,
env,
network: network.clone(),
parent_tool_item_id,
sandbox_permissions,
windows_sandbox_level,
windows_sandbox_private_desktop,
@@ -448,6 +455,7 @@ async fn exec_windows_sandbox(
cwd,
mut env,
network,
parent_tool_item_id,
expiration,
capture_policy,
windows_sandbox_level,
@@ -455,7 +463,7 @@ async fn exec_windows_sandbox(
..
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_parent_tool_item(&mut env, parent_tool_item_id.as_deref());
}
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
@@ -838,6 +846,7 @@ async fn exec(
cwd,
mut env,
network,
parent_tool_item_id,
arg0,
expiration,
capture_policy,
@@ -845,7 +854,7 @@ async fn exec(
..
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_parent_tool_item(&mut env, parent_tool_item_id.as_deref());
}
let (program, args) = command.split_first().ok_or_else(|| {

View File

@@ -259,6 +259,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
capture_policy: ExecCapturePolicy::FullBuffer,
env,
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
@@ -298,6 +299,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
capture_policy: ExecCapturePolicy::FullBuffer,
env: std::env::vars().collect(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
@@ -348,6 +350,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
capture_policy: ExecCapturePolicy::FullBuffer,
env: std::env::vars().collect(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
@@ -588,6 +591,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
@@ -646,6 +650,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,

View File

@@ -47,6 +47,7 @@ pub(crate) enum GuardianApprovalRequest {
NetworkAccess {
id: String,
turn_id: String,
parent_tool_item_id: Option<String>,
target: String,
host: String,
protocol: NetworkApprovalProtocol,
@@ -252,6 +253,7 @@ pub(crate) fn guardian_approval_request_to_json(
host,
protocol,
port,
..
} => Ok(serde_json::json!({
"tool": "network_access",
"target": target,
@@ -324,6 +326,7 @@ pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest)
host,
protocol,
port,
..
} => serde_json::json!({
"tool": "network_access",
"target": target,

View File

@@ -348,6 +348,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
let network_access = GuardianApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
parent_tool_item_id: Some("command-1".to_string()),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,

View File

@@ -156,6 +156,7 @@ fn denied_network_policy_message_requires_deny_decision() {
let blocked = BlockedRequest {
host: "example.com".to_string(),
reason: "not_allowed".to_string(),
parent_tool_item_id: None,
client: None,
method: Some("GET".to_string()),
mode: None,
@@ -173,6 +174,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() {
let blocked = BlockedRequest {
host: "example.com".to_string(),
reason: "denied".to_string(),
parent_tool_item_id: None,
client: None,
method: Some("GET".to_string()),
mode: None,

View File

@@ -68,6 +68,9 @@ pub struct ExecRequest {
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
/// Parent tool item id propagated into child process proxy settings for
/// blocked-request attribution.
pub parent_tool_item_id: Option<String>,
pub expiration: ExecExpiration,
pub capture_policy: ExecCapturePolicy,
pub sandbox: SandboxType,
@@ -94,6 +97,10 @@ pub(crate) struct SandboxTransformRequest<'a> {
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
// to make shared ownership explicit across runtime/sandbox plumbing.
pub network: Option<&'a NetworkProxy>,
/// Parent tool item id for the current tool call. This is threaded into
/// managed proxy credentials so the proxy can report blocked requests
/// against the exact originating call.
pub parent_tool_item_id: Option<&'a str>,
pub sandbox_policy_cwd: &'a Path,
#[cfg(target_os = "macos")]
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
@@ -592,6 +599,7 @@ impl SandboxManager {
sandbox,
enforce_managed_network,
network,
parent_tool_item_id,
sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
@@ -709,6 +717,7 @@ impl SandboxManager {
cwd: spec.cwd,
env,
network: network.cloned(),
parent_tool_item_id: parent_tool_item_id.map(ToString::to_string),
expiration: spec.expiration,
capture_policy: spec.capture_policy,
sandbox,

View File

@@ -171,6 +171,7 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network()
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
parent_tool_item_id: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
@@ -541,6 +542,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() {
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
parent_tool_item_id: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
@@ -615,6 +617,7 @@ fn transform_additional_permissions_preserves_denied_entries() {
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
parent_tool_item_id: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,

View File

@@ -163,6 +163,7 @@ pub(crate) async fn execute_user_shell_command(
Some(session.conversation_id),
),
network: turn_context.network.clone(),
parent_tool_item_id: None,
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),

View File

@@ -74,6 +74,7 @@ impl ShellHandler {
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
parent_tool_item_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
@@ -129,6 +130,7 @@ impl ShellCommandHandler {
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
parent_tool_item_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context

View File

@@ -1067,6 +1067,7 @@ impl JsReplManager {
sandbox: sandbox_type,
enforce_managed_network: has_managed_network_requirements,
network: None,
parent_tool_item_id: None,
sandbox_policy_cwd: &turn.cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,

View File

@@ -28,7 +28,6 @@ use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::RwLock;
use tracing::warn;
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NetworkApprovalMode {
@@ -44,18 +43,18 @@ pub(crate) struct NetworkApprovalSpec {
#[derive(Clone, Debug)]
pub(crate) struct DeferredNetworkApproval {
registration_id: String,
parent_tool_item_id: String,
}
impl DeferredNetworkApproval {
pub(crate) fn registration_id(&self) -> &str {
&self.registration_id
pub(crate) fn parent_tool_item_id(&self) -> &str {
&self.parent_tool_item_id
}
}
#[derive(Debug)]
pub(crate) struct ActiveNetworkApproval {
registration_id: Option<String>,
parent_tool_item_id: Option<String>,
mode: NetworkApprovalMode,
}
@@ -65,9 +64,11 @@ impl ActiveNetworkApproval {
}
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
match (self.mode, self.registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
Some(DeferredNetworkApproval { registration_id })
match (self.mode, self.parent_tool_item_id) {
(NetworkApprovalMode::Deferred, Some(parent_tool_item_id)) => {
Some(DeferredNetworkApproval {
parent_tool_item_id,
})
}
_ => None,
}
@@ -160,8 +161,8 @@ impl PendingHostApproval {
}
struct ActiveNetworkApprovalCall {
registration_id: String,
turn_id: String,
parent_tool_item_id: String,
}
pub(crate) struct NetworkApprovalService {
@@ -194,23 +195,35 @@ impl NetworkApprovalService {
other_approved_hosts.extend(approved_hosts.iter().cloned());
}
async fn register_call(&self, registration_id: String, turn_id: String) {
async fn register_call(&self, turn_id: String, parent_tool_item_id: String) {
let mut active_calls = self.active_calls.lock().await;
let key = registration_id.clone();
let key = parent_tool_item_id.clone();
active_calls.insert(
key,
Arc::new(ActiveNetworkApprovalCall {
registration_id,
turn_id,
parent_tool_item_id,
}),
);
}
pub(crate) async fn unregister_call(&self, registration_id: &str) {
pub(crate) async fn unregister_call(&self, parent_tool_item_id: &str) {
let mut active_calls = self.active_calls.lock().await;
active_calls.shift_remove(registration_id);
active_calls.shift_remove(parent_tool_item_id);
let mut call_outcomes = self.call_outcomes.lock().await;
call_outcomes.remove(registration_id);
call_outcomes.remove(parent_tool_item_id);
}
async fn resolve_active_call(
&self,
parent_tool_item_id: Option<&str>,
) -> Option<Arc<ActiveNetworkApprovalCall>> {
if let Some(parent_tool_item_id) = parent_tool_item_id {
let active_calls = self.active_calls.lock().await;
return active_calls.get(parent_tool_item_id).cloned();
}
self.resolve_single_active_call().await
}
async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {
@@ -236,28 +249,36 @@ impl NetworkApprovalService {
(created, true)
}
async fn record_outcome_for_single_active_call(&self, outcome: NetworkApprovalOutcome) {
let Some(owner_call) = self.resolve_single_active_call().await else {
async fn record_outcome_for_active_call(
&self,
parent_tool_item_id: Option<&str>,
outcome: NetworkApprovalOutcome,
) {
let Some(active_call) = self.resolve_active_call(parent_tool_item_id).await else {
return;
};
self.record_call_outcome(&owner_call.registration_id, outcome)
self.record_call_outcome(&active_call.parent_tool_item_id, outcome)
.await;
}
async fn take_call_outcome(&self, registration_id: &str) -> Option<NetworkApprovalOutcome> {
async fn take_call_outcome(&self, parent_tool_item_id: &str) -> Option<NetworkApprovalOutcome> {
let mut call_outcomes = self.call_outcomes.lock().await;
call_outcomes.remove(registration_id)
call_outcomes.remove(parent_tool_item_id)
}
async fn record_call_outcome(&self, registration_id: &str, outcome: NetworkApprovalOutcome) {
async fn record_call_outcome(
&self,
parent_tool_item_id: &str,
outcome: NetworkApprovalOutcome,
) {
let mut call_outcomes = self.call_outcomes.lock().await;
if matches!(
call_outcomes.get(registration_id),
call_outcomes.get(parent_tool_item_id),
Some(NetworkApprovalOutcome::DeniedByUser)
) {
return;
}
call_outcomes.insert(registration_id.to_string(), outcome);
call_outcomes.insert(parent_tool_item_id.to_string(), outcome);
}
pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {
@@ -265,8 +286,11 @@ impl NetworkApprovalService {
return;
};
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))
.await;
self.record_outcome_for_active_call(
blocked.parent_tool_item_id.as_deref(),
NetworkApprovalOutcome::DeniedByPolicy(message),
)
.await;
}
async fn active_turn_context(session: &Session) -> Option<Arc<crate::codex::TurnContext>> {
@@ -328,9 +352,10 @@ impl NetworkApprovalService {
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
policy_denial_message,
))
self.record_outcome_for_active_call(
request.parent_tool_item_id.as_deref(),
NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message),
)
.await;
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
@@ -338,9 +363,10 @@ impl NetworkApprovalService {
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
policy_denial_message,
))
self.record_outcome_for_active_call(
request.parent_tool_item_id.as_deref(),
NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message),
)
.await;
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
@@ -349,18 +375,23 @@ impl NetworkApprovalService {
host: request.host.clone(),
protocol,
};
let owner_call = self.resolve_single_active_call().await;
let active_call = self
.resolve_active_call(request.parent_tool_item_id.as_deref())
.await;
let approval_decision = if routes_approval_to_guardian(&turn_context) {
// TODO(ccunningham): Attach guardian network reviews to the reviewed tool item
// lifecycle instead of this temporary standalone network approval id.
// lifecycle instead of this temporary standalone network review id.
review_approval_request(
&session,
&turn_context,
GuardianApprovalRequest::NetworkAccess {
id: Self::approval_id_for_key(&key),
turn_id: owner_call
turn_id: active_call
.as_ref()
.map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),
parent_tool_item_id: active_call
.as_ref()
.map(|call| call.parent_tool_item_id.clone()),
target,
host: request.host,
protocol,
@@ -457,9 +488,9 @@ impl NetworkApprovalService {
.await;
}
}
if let Some(owner_call) = owner_call.as_ref() {
if let Some(active_call) = active_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
&active_call.parent_tool_item_id,
NetworkApprovalOutcome::DeniedByUser,
)
.await;
@@ -470,18 +501,18 @@ impl NetworkApprovalService {
},
ReviewDecision::Denied | ReviewDecision::Abort => {
if routes_approval_to_guardian(&turn_context) {
if let Some(owner_call) = owner_call.as_ref() {
if let Some(active_call) = active_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
&active_call.parent_tool_item_id,
NetworkApprovalOutcome::DeniedByPolicy(
GUARDIAN_REJECTION_MESSAGE.to_string(),
),
)
.await;
}
} else if let Some(owner_call) = owner_call.as_ref() {
} else if let Some(active_call) = active_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
&active_call.parent_tool_item_id,
NetworkApprovalOutcome::DeniedByUser,
)
.await;
@@ -548,6 +579,7 @@ pub(crate) fn build_network_policy_decider(
pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
parent_tool_item_id: &str,
has_managed_network_requirements: bool,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
@@ -556,15 +588,14 @@ pub(crate) async fn begin_network_approval(
return None;
}
let registration_id = Uuid::new_v4().to_string();
session
.services
.network_approval
.register_call(registration_id.clone(), turn_id.to_string())
.register_call(turn_id.to_string(), parent_tool_item_id.to_string())
.await;
Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
parent_tool_item_id: Some(parent_tool_item_id.to_string()),
mode: spec.mode,
})
}
@@ -573,20 +604,20 @@ pub(crate) async fn finish_immediate_network_approval(
session: &Session,
active: ActiveNetworkApproval,
) -> Result<(), ToolError> {
let Some(registration_id) = active.registration_id.as_deref() else {
let Some(parent_tool_item_id) = active.parent_tool_item_id.as_deref() else {
return Ok(());
};
let approval_outcome = session
.services
.network_approval
.take_call_outcome(registration_id)
.take_call_outcome(parent_tool_item_id)
.await;
session
.services
.network_approval
.unregister_call(registration_id)
.unregister_call(parent_tool_item_id)
.await;
match approval_outcome {
@@ -608,7 +639,7 @@ pub(crate) async fn finish_deferred_network_approval(
session
.services
.network_approval
.unregister_call(deferred.registration_id())
.unregister_call(deferred.parent_tool_item_id())
.await;
}

View File

@@ -180,9 +180,17 @@ fn only_never_policy_disables_network_approval_flow() {
}
fn denied_blocked_request(host: &str) -> BlockedRequest {
denied_blocked_request_with_parent_tool_item(host, None)
}
fn denied_blocked_request_with_parent_tool_item(
host: &str,
parent_tool_item_id: Option<&str>,
) -> BlockedRequest {
BlockedRequest::new(BlockedRequestArgs {
host: host.to_string(),
reason: "not_allowed".to_string(),
parent_tool_item_id: parent_tool_item_id.map(ToString::to_string),
client: None,
method: None,
mode: None,
@@ -194,10 +202,10 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
}
#[tokio::test]
async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
async fn record_blocked_request_sets_policy_outcome_for_active_call() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call("turn-1".to_string(), "command-1".to_string())
.await;
service
@@ -205,7 +213,7 @@ async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
.await;
assert_eq!(
service.take_call_outcome("registration-1").await,
service.take_call_outcome("command-1").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
))
@@ -216,18 +224,18 @@ async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
async fn blocked_request_policy_does_not_override_user_denial_outcome() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call("turn-1".to_string(), "command-1".to_string())
.await;
service
.record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser)
.record_call_outcome("command-1", NetworkApprovalOutcome::DeniedByUser)
.await;
service
.record_blocked_request(denied_blocked_request("example.com"))
.await;
assert_eq!(
service.take_call_outcome("registration-1").await,
service.take_call_outcome("command-1").await,
Some(NetworkApprovalOutcome::DeniedByUser)
);
}
@@ -236,16 +244,61 @@ async fn blocked_request_policy_does_not_override_user_denial_outcome() {
async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call("turn-1".to_string(), "command-1".to_string())
.await;
service
.register_call("registration-2".to_string(), "turn-1".to_string())
.register_call("turn-1".to_string(), "command-2".to_string())
.await;
service
.record_blocked_request(denied_blocked_request("example.com"))
.await;
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(service.take_call_outcome("registration-2").await, None);
assert_eq!(service.take_call_outcome("command-1").await, None);
assert_eq!(service.take_call_outcome("command-2").await, None);
}
#[tokio::test]
async fn resolve_active_call_uses_parent_tool_item_id_when_multiple_calls_are_active() {
let service = NetworkApprovalService::default();
service
.register_call("turn-1".to_string(), "command-1".to_string())
.await;
service
.register_call("turn-2".to_string(), "command-2".to_string())
.await;
let active_call = service
.resolve_active_call(Some("command-2"))
.await
.expect("active call should resolve");
assert_eq!(active_call.turn_id, "turn-2");
assert_eq!(active_call.parent_tool_item_id, "command-2");
}
#[tokio::test]
async fn record_blocked_request_uses_parent_tool_item_id_when_multiple_calls_are_active() {
let service = NetworkApprovalService::default();
service
.register_call("turn-1".to_string(), "command-1".to_string())
.await;
service
.register_call("turn-2".to_string(), "command-2".to_string())
.await;
service
.record_blocked_request(denied_blocked_request_with_parent_tool_item(
"example.com",
Some("command-2"),
))
.await;
assert_eq!(service.take_call_outcome("command-1").await, None);
assert_eq!(
service.take_call_outcome("command-2").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
))
);
}

View File

@@ -60,18 +60,12 @@ impl ToolOrchestrator {
let network_approval = begin_network_approval(
&tool_ctx.session,
&tool_ctx.turn.sub_id,
&tool_ctx.call_id,
has_managed_network_requirements,
tool.network_approval_spec(req, tool_ctx),
)
.await;
let attempt_tool_ctx = ToolCtx {
session: tool_ctx.session.clone(),
turn: tool_ctx.turn.clone(),
call_id: tool_ctx.call_id.clone(),
tool_name: tool_ctx.tool_name.clone(),
};
let run_result = tool.run(req, attempt, &attempt_tool_ctx).await;
let run_result = tool.run(req, attempt, tool_ctx).await;
let Some(network_approval) = network_approval else {
return (run_result, None);

View File

@@ -208,7 +208,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
) -> Result<ExecToolCallOutput, ToolError> {
let spec = Self::build_command_spec(req, &ctx.turn.config.codex_home)?;
let env = attempt
.env_for(spec, /*network*/ None)
.env_for(
spec, /*network*/ None, /*parent_tool_item_id*/ None,
)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, Self::stdout_stream(ctx))
.await

View File

@@ -253,7 +253,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.justification.clone(),
)?;
let env = attempt
.env_for(spec, req.network.as_ref())
.env_for(spec, req.network.as_ref(), Some(ctx.call_id.as_str()))
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, Self::stdout_stream(ctx))
.await

View File

@@ -117,13 +117,14 @@ pub(super) async fn try_run_zsh_fork(
req.justification.clone(),
)?;
let sandbox_exec_request = attempt
.env_for(spec, req.network.as_ref())
.env_for(spec, req.network.as_ref(), Some(ctx.call_id.as_str()))
.map_err(|err| ToolError::Codex(err.into()))?;
let crate::sandboxing::ExecRequest {
command,
cwd: sandbox_cwd,
env: sandbox_env,
network: sandbox_network,
parent_tool_item_id,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
sandbox,
@@ -153,6 +154,7 @@ pub(super) async fn try_run_zsh_fork(
sandbox,
env: sandbox_env,
network: sandbox_network,
parent_tool_item_id,
windows_sandbox_level,
sandbox_permissions,
justification,
@@ -259,6 +261,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
sandbox: exec_request.sandbox,
env: exec_request.env.clone(),
network: exec_request.network.clone(),
parent_tool_item_id: exec_request.parent_tool_item_id.clone(),
windows_sandbox_level: exec_request.windows_sandbox_level,
sandbox_permissions: exec_request.sandbox_permissions,
justification: exec_request.justification.clone(),
@@ -856,6 +859,7 @@ struct CoreShellCommandExecutor {
sandbox: SandboxType,
env: HashMap<String, String>,
network: Option<codex_network_proxy::NetworkProxy>,
parent_tool_item_id: Option<String>,
windows_sandbox_level: WindowsSandboxLevel,
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
@@ -904,6 +908,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
cwd: self.cwd.clone(),
env: exec_env,
network: self.network.clone(),
parent_tool_item_id: self.parent_tool_item_id.clone(),
expiration: ExecExpiration::Cancellation(cancel_rx),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox: self.sandbox,
@@ -1060,6 +1065,7 @@ impl CoreShellCommandExecutor {
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),
parent_tool_item_id: self.parent_tool_item_id.as_deref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions,
@@ -1069,7 +1075,10 @@ impl CoreShellCommandExecutor {
windows_sandbox_private_desktop: false,
})?;
if let Some(network) = exec_request.network.as_ref() {
network.apply_to_env(&mut exec_request.env);
network.apply_to_env_for_parent_tool_item(
&mut exec_request.env,
exec_request.parent_tool_item_id.as_deref(),
);
}
Ok(PreparedExec {

View File

@@ -655,6 +655,7 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
@@ -707,6 +708,7 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
@@ -782,6 +784,7 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox: SandboxType::None,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),

View File

@@ -207,7 +207,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let mut env = req.env.clone();
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_parent_tool_item(&mut env, Some(ctx.call_id.as_str()));
}
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let spec = build_command_spec(
@@ -221,7 +221,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.env_for(spec, req.network.as_ref(), Some(ctx.call_id.as_str()))
.map_err(|err| ToolError::Codex(err.into()))?;
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
@@ -269,7 +269,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(spec, req.network.as_ref())
.env_for(spec, req.network.as_ref(), Some(ctx.call_id.as_str()))
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle))

View File

@@ -341,6 +341,7 @@ impl<'a> SandboxAttempt<'a> {
&self,
spec: CommandSpec,
network: Option<&NetworkProxy>,
parent_tool_item_id: Option<&str>,
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
self.manager
.transform(crate::sandboxing::SandboxTransformRequest {
@@ -351,6 +352,7 @@ impl<'a> SandboxAttempt<'a> {
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
network,
parent_tool_item_id,
sandbox_policy_cwd: self.sandbox_cwd,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,

View File

@@ -198,7 +198,7 @@ impl UnifiedExecProcessManager {
if process_started_alive {
let network_approval_id = deferred_network_approval
.as_ref()
.map(|deferred| deferred.registration_id().to_string());
.map(|deferred| deferred.parent_tool_item_id().to_string());
self.store_process(
Arc::clone(&process),
context,

View File

@@ -41,6 +41,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,

View File

@@ -120,6 +120,7 @@ async fn run_cmd_result_with_policies(
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env_from_core_vars(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
@@ -380,6 +381,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env_from_core_vars(),
network: None,
parent_tool_item_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,

View File

@@ -50,6 +50,7 @@ use rama_http::StatusCode;
use rama_http::header;
use rama_http::headers::HeaderMapExt;
use rama_http::headers::Host;
use rama_http::headers::ProxyAuthorization;
use rama_http::layer::remove_header::RemoveResponseHeaderLayer;
use rama_http::matcher::MethodMatcher;
use rama_http_backend::client::proxy::layer::HttpProxyConnector;
@@ -65,6 +66,7 @@ use rama_net::proxy::ProxyRequest;
use rama_net::proxy::ProxyTarget;
use rama_net::proxy::StreamForwardService;
use rama_net::stream::SocketInfo;
use rama_net::user::Basic;
use rama_tcp::client::Request as TcpRequest;
use rama_tcp::client::service::TcpConnector;
use rama_tcp::server::TcpListener;
@@ -149,6 +151,12 @@ async fn run_http_proxy_with_listener(
Ok(())
}
fn proxy_authorization_parent_tool_item_id<T>(req: &Request<T>) -> Option<String> {
req.headers()
.typed_get::<ProxyAuthorization<Basic>>()
.map(|header| header.0.username().to_string())
}
async fn http_connect_accept(
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
mut req: Request,
@@ -173,6 +181,7 @@ async fn http_connect_accept(
}
let client = client_addr(&req);
let parent_tool_item_id = proxy_authorization_parent_tool_item_id(&req);
let enabled = app_state
.enabled()
.await
@@ -182,12 +191,15 @@ async fn http_connect_accept(
warn!("CONNECT blocked; proxy disabled (client={client}, host={host})");
return Err(proxy_disabled_response(
&app_state,
host,
authority.port,
client_addr(&req),
Some("CONNECT".to_string()),
NetworkProtocol::HttpsConnect,
/*audit_endpoint_override*/ None,
ProxyDisabledResponseArgs {
host,
port: authority.port,
client: client_addr(&req),
parent_tool_item_id,
method: Some("CONNECT".to_string()),
protocol: NetworkProtocol::HttpsConnect,
audit_endpoint_override: None,
},
)
.await);
}
@@ -196,6 +208,7 @@ async fn http_connect_accept(
protocol: NetworkProtocol::HttpsConnect,
host: host.clone(),
port: authority.port,
parent_tool_item_id: parent_tool_item_id.clone(),
client_addr: client.clone(),
method: Some("CONNECT".to_string()),
command: None,
@@ -220,6 +233,7 @@ async fn http_connect_accept(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: reason.clone(),
parent_tool_item_id,
client: client.clone(),
method: Some("CONNECT".to_string()),
mode: None,
@@ -283,6 +297,7 @@ async fn http_connect_accept(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_MITM_REQUIRED.to_string(),
parent_tool_item_id,
client: client.clone(),
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
@@ -432,6 +447,7 @@ async fn http_plain_proxy(
}
};
let client = client_addr(&req);
let parent_tool_item_id = proxy_authorization_parent_tool_item_id(&req);
let method_allowed = match app_state
.method_allowed(req.method().as_str())
.await
@@ -468,12 +484,15 @@ async fn http_plain_proxy(
warn!("unix socket blocked; proxy disabled (client={client}, path={socket_path})");
return Ok(proxy_disabled_response(
&app_state,
socket_path,
/*port*/ 0,
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
Some(("unix-socket", 0)),
ProxyDisabledResponseArgs {
host: socket_path,
port: 0,
client: client_addr(&req),
parent_tool_item_id: parent_tool_item_id.clone(),
method: Some(req.method().as_str().to_string()),
protocol: NetworkProtocol::Http,
audit_endpoint_override: Some(("unix-socket", 0)),
},
)
.await);
}
@@ -613,12 +632,15 @@ async fn http_plain_proxy(
warn!("request blocked; proxy disabled (client={client}, host={host}, method={method})");
return Ok(proxy_disabled_response(
&app_state,
host,
port,
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
/*audit_endpoint_override*/ None,
ProxyDisabledResponseArgs {
host,
port,
client: client_addr(&req),
parent_tool_item_id: parent_tool_item_id.clone(),
method: Some(req.method().as_str().to_string()),
protocol: NetworkProtocol::Http,
audit_endpoint_override: None,
},
)
.await);
}
@@ -627,6 +649,7 @@ async fn http_plain_proxy(
protocol: NetworkProtocol::Http,
host: host.clone(),
port,
parent_tool_item_id: parent_tool_item_id.clone(),
client_addr: client.clone(),
method: Some(req.method().as_str().to_string()),
command: None,
@@ -651,6 +674,7 @@ async fn http_plain_proxy(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: reason.clone(),
parent_tool_item_id: parent_tool_item_id.clone(),
client: client.clone(),
method: Some(req.method().as_str().to_string()),
mode: None,
@@ -696,6 +720,7 @@ async fn http_plain_proxy(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
parent_tool_item_id,
client: client.clone(),
method: Some(req.method().as_str().to_string()),
mode: Some(NetworkMode::Limited),
@@ -884,15 +909,29 @@ fn blocked_text_with_details(reason: &str, details: &PolicyDecisionDetails<'_>)
blocked_text_response_with_policy(reason, details)
}
async fn proxy_disabled_response(
app_state: &NetworkProxyState,
struct ProxyDisabledResponseArgs {
host: String,
port: u16,
client: Option<String>,
parent_tool_item_id: Option<String>,
method: Option<String>,
protocol: NetworkProtocol,
audit_endpoint_override: Option<(&str, u16)>,
audit_endpoint_override: Option<(&'static str, u16)>,
}
async fn proxy_disabled_response(
app_state: &NetworkProxyState,
args: ProxyDisabledResponseArgs,
) -> Response {
let ProxyDisabledResponseArgs {
host,
port,
client,
parent_tool_item_id,
method,
protocol,
audit_endpoint_override,
} = args;
let (audit_server_address, audit_server_port) =
audit_endpoint_override.unwrap_or((host.as_str(), port));
emit_http_block_decision_audit_event(
@@ -913,6 +952,7 @@ async fn proxy_disabled_response(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: blocked_host,
reason: REASON_PROXY_DISABLED.to_string(),
parent_tool_item_id,
client,
method,
mode: None,
@@ -1013,9 +1053,10 @@ mod tests {
.method(Method::CONNECT)
.uri("https://example.com:443")
.header("host", "example.com:443")
.header(header::PROXY_AUTHORIZATION, "Basic Y29tbWFuZC0xOg==")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
req.extensions_mut().insert(Arc::clone(&state));
let response = http_connect_accept(None, req).await.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -1023,6 +1064,26 @@ mod tests {
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-mitm-required"
);
let blocked = state
.blocked_snapshot()
.await
.expect("blocked snapshot should succeed");
assert_eq!(
blocked,
vec![BlockedRequest {
host: "example.com".to_string(),
reason: REASON_MITM_REQUIRED.to_string(),
parent_tool_item_id: Some("command-1".to_string()),
client: None,
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
protocol: "http-connect".to_string(),
decision: Some("deny".to_string()),
source: Some("mode_guard".to_string()),
port: Some(443),
timestamp: blocked[0].timestamp,
}]
);
}
#[tokio::test]

View File

@@ -5,6 +5,7 @@ mod config;
mod http_proxy;
mod mitm;
mod network_policy;
mod owner_identity;
mod policy;
mod proxy;
mod reasons;

View File

@@ -289,6 +289,7 @@ async fn mitm_blocking_response(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: policy.target_host.clone(),
reason: reason.to_string(),
parent_tool_item_id: None,
client: client.clone(),
method: Some(method.clone()),
mode: Some(policy.mode),
@@ -311,6 +312,7 @@ async fn mitm_blocking_response(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: policy.target_host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
parent_tool_item_id: None,
client: client.clone(),
method: Some(method.clone()),
mode: Some(policy.mode),

View File

@@ -79,6 +79,10 @@ pub struct NetworkPolicyRequest {
pub protocol: NetworkProtocol,
pub host: String,
pub port: u16,
/// Parent tool item id forwarded from the originating tool call. Core uses
/// this to resolve blocked requests back to the exact active call instead
/// of guessing by session state.
pub parent_tool_item_id: Option<String>,
pub client_addr: Option<String>,
pub method: Option<String>,
pub command: Option<String>,
@@ -89,6 +93,9 @@ pub struct NetworkPolicyRequestArgs {
pub protocol: NetworkProtocol,
pub host: String,
pub port: u16,
/// Parent tool item id forwarded from the originating tool call for later
/// attribution.
pub parent_tool_item_id: Option<String>,
pub client_addr: Option<String>,
pub method: Option<String>,
pub command: Option<String>,
@@ -101,6 +108,7 @@ impl NetworkPolicyRequest {
protocol,
host,
port,
parent_tool_item_id,
client_addr,
method,
command,
@@ -110,6 +118,7 @@ impl NetworkPolicyRequest {
protocol,
host,
port,
parent_tool_item_id,
client_addr,
method,
command,
@@ -625,6 +634,7 @@ mod tests {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
parent_tool_item_id: None,
client_addr: None,
method: None,
command: None,
@@ -685,6 +695,7 @@ mod tests {
protocol: NetworkProtocol::Http,
host: "blocked.com".to_string(),
port: 80,
parent_tool_item_id: None,
client_addr: Some("127.0.0.1:1234".to_string()),
method: Some("GET".to_string()),
command: None,
@@ -726,6 +737,7 @@ mod tests {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
parent_tool_item_id: None,
client_addr: None,
method: Some("GET".to_string()),
command: None,
@@ -776,6 +788,7 @@ mod tests {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
parent_tool_item_id: None,
client_addr: None,
method: Some("GET".to_string()),
command: None,
@@ -859,6 +872,7 @@ mod tests {
protocol: NetworkProtocol::Http,
host: "127.0.0.1".to_string(),
port: 80,
parent_tool_item_id: None,
client_addr: None,
method: Some("GET".to_string()),
command: None,

View File

@@ -0,0 +1,41 @@
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsRef;
use rama_http::headers::authorization::AuthoritySync;
use rama_net::user::Basic;
use rama_net::user::UserId;
use rama_net::user::authority::AuthorizeResult;
use rama_net::user::authority::Authorizer;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ProxyParentToolItemAuthorizer;
impl AuthoritySync<Basic, ()> for ProxyParentToolItemAuthorizer {
fn authorized(&self, ext: &mut Extensions, credentials: &Basic) -> bool {
ext.insert(UserId::Username(credentials.username().to_string()));
true
}
}
impl Authorizer<Basic> for ProxyParentToolItemAuthorizer {
type Error = std::convert::Infallible;
async fn authorize(&self, credentials: Basic) -> AuthorizeResult<Basic, Self::Error> {
let mut extensions = Extensions::new();
extensions.insert(UserId::Username(credentials.username().to_string()));
AuthorizeResult {
credentials,
result: Ok(Some(extensions)),
}
}
}
/// Extract the parent tool item id from proxy auth/user extensions. Managed
/// proxy callbacks carry the parent tool item id as the proxy auth username so
/// core can attribute blocked requests back to the originating tool call.
pub(crate) fn extract_parent_tool_item_id<T: ExtensionsRef>(input: &T) -> Option<String> {
match input.extensions().get::<UserId>() {
Some(UserId::Username(username)) => Some(username.clone()),
Some(UserId::Token(token)) => String::from_utf8(token.clone()).ok(),
Some(UserId::Anonymous) | None => None,
}
}

View File

@@ -15,6 +15,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use tokio::task::JoinHandle;
use tracing::warn;
use url::Url;
#[derive(Debug, Clone, Parser)]
#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")]
@@ -305,15 +306,34 @@ fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
}
}
fn proxy_url(scheme: &str, addr: SocketAddr, parent_tool_item_id: Option<&str>) -> String {
let base = format!("{scheme}://{addr}");
let mut url = match Url::parse(&base) {
Ok(url) => url,
Err(err) => panic!("failed to build proxy URL for {base}: {err}"),
};
if let Some(parent_tool_item_id) = parent_tool_item_id
&& let Err(()) = url.set_username(parent_tool_item_id)
{
panic!("failed to encode parent tool item id in proxy URL");
}
let mut proxy_url = url.to_string();
if proxy_url.ends_with('/') {
proxy_url.pop();
}
proxy_url
}
fn apply_proxy_env_overrides(
env: &mut HashMap<String, String>,
http_addr: SocketAddr,
socks_addr: SocketAddr,
socks_enabled: bool,
allow_local_binding: bool,
parent_tool_item_id: Option<&str>,
) {
let http_proxy_url = format!("http://{http_addr}");
let socks_proxy_url = format!("socks5h://{socks_addr}");
let http_proxy_url = proxy_url("http", http_addr, parent_tool_item_id);
let socks_proxy_url = proxy_url("socks5h", socks_addr, parent_tool_item_id);
env.insert(
ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
if allow_local_binding {
@@ -414,6 +434,16 @@ impl NetworkProxy {
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
self.apply_to_env_for_parent_tool_item(env, /*parent_tool_item_id*/ None);
}
/// Apply managed proxy environment variables, optionally tagging them with
/// the originating parent tool item id for blocked-request attribution.
pub fn apply_to_env_for_parent_tool_item(
&self,
env: &mut HashMap<String, String>,
parent_tool_item_id: Option<&str>,
) {
// Enforce proxying for child processes. We intentionally override existing values so
// command-level environment cannot bypass the managed proxy endpoint.
apply_proxy_env_overrides(
@@ -422,6 +452,7 @@ impl NetworkProxy {
self.socks_addr,
self.socks_enabled,
self.allow_local_binding,
parent_tool_item_id,
);
}
@@ -696,6 +727,7 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
/*parent_tool_item_id*/ None,
);
assert_eq!(
@@ -746,6 +778,7 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
false,
true,
/*parent_tool_item_id*/ None,
);
assert_eq!(
@@ -764,6 +797,7 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
/*parent_tool_item_id*/ None,
);
assert_eq!(
@@ -809,6 +843,7 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
/*parent_tool_item_id*/ None,
);
assert_eq!(
@@ -816,4 +851,26 @@ mod tests {
Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
);
}
#[test]
fn apply_proxy_env_overrides_includes_owner_credentials() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
Some("owner-1"),
);
assert_eq!(
env.get("HTTP_PROXY"),
Some(&"http://owner-1@127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"socks5h://owner-1@127.0.0.1:8081".to_string())
);
}
}

View File

@@ -80,10 +80,15 @@ pub enum HostBlockDecision {
Blocked(HostBlockReason),
}
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BlockedRequest {
pub host: String,
pub reason: String,
/// Parent tool item id carried alongside the blocked request for
/// attribution in core. This is internal-only and is intentionally omitted
/// from serialized logs/snapshots.
#[serde(skip_serializing)]
pub parent_tool_item_id: Option<String>,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
@@ -100,6 +105,9 @@ pub struct BlockedRequest {
pub struct BlockedRequestArgs {
pub host: String,
pub reason: String,
/// Parent tool item id carried alongside the blocked request for
/// attribution in core.
pub parent_tool_item_id: Option<String>,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
@@ -114,6 +122,7 @@ impl BlockedRequest {
let BlockedRequestArgs {
host,
reason,
parent_tool_item_id,
client,
method,
mode,
@@ -125,6 +134,7 @@ impl BlockedRequest {
Self {
host,
reason,
parent_tool_item_id,
client,
method,
mode,
@@ -994,6 +1004,7 @@ mod tests {
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: "google.com".to_string(),
reason: "not_allowed".to_string(),
parent_tool_item_id: None,
client: None,
method: Some("GET".to_string()),
mode: None,
@@ -1034,6 +1045,7 @@ mod tests {
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: format!("example{idx}.com"),
reason: "not_allowed".to_string(),
parent_tool_item_id: None,
client: None,
method: Some("GET".to_string()),
mode: None,
@@ -1056,6 +1068,7 @@ mod tests {
let entry = BlockedRequest {
host: "google.com".to_string(),
reason: "not_allowed".to_string(),
parent_tool_item_id: None,
client: Some("127.0.0.1".to_string()),
method: Some("GET".to_string()),
mode: Some(NetworkMode::Full),

View File

@@ -9,6 +9,8 @@ use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::owner_identity::ProxyParentToolItemAuthorizer;
use crate::owner_identity::extract_parent_tool_item_id;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_PROXY_DISABLED;
@@ -105,7 +107,11 @@ async fn run_socks5_with_listener(
});
let socks_connector = DefaultConnector::default().with_connector(policy_tcp_connector);
let base = Socks5Acceptor::new().with_connector(socks_connector);
let mut base = Socks5Acceptor::new();
base.set_auth_optional(true);
let base = base
.with_connector(socks_connector)
.with_authorizer(ProxyParentToolItemAuthorizer);
if enable_socks5_udp {
let udp_state = state.clone();
@@ -150,6 +156,7 @@ async fn handle_socks5_tcp(
.extensions()
.get::<SocketInfo>()
.map(|info| info.peer_addr().to_string());
let parent_tool_item_id = extract_parent_tool_item_id(&req);
match app_state.enabled().await {
Ok(true) => {}
@@ -175,6 +182,7 @@ async fn handle_socks5_tcp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_PROXY_DISABLED.to_string(),
parent_tool_item_id: parent_tool_item_id.clone(),
client: client.clone(),
method: None,
mode: None,
@@ -217,6 +225,7 @@ async fn handle_socks5_tcp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
parent_tool_item_id: parent_tool_item_id.clone(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Limited),
@@ -243,6 +252,7 @@ async fn handle_socks5_tcp(
protocol: NetworkProtocol::Socks5Tcp,
host: host.clone(),
port,
parent_tool_item_id: parent_tool_item_id.clone(),
client_addr: client.clone(),
method: None,
command: None,
@@ -267,6 +277,7 @@ async fn handle_socks5_tcp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: reason.clone(),
parent_tool_item_id,
client: client.clone(),
method: None,
mode: None,
@@ -314,6 +325,7 @@ async fn inspect_socks5_udp(
let client = extensions
.get::<SocketInfo>()
.map(|info| info.peer_addr().to_string());
let parent_tool_item_id = extract_parent_tool_item_id(&extensions);
match state.enabled().await {
Ok(true) => {}
@@ -339,6 +351,7 @@ async fn inspect_socks5_udp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_PROXY_DISABLED.to_string(),
parent_tool_item_id: parent_tool_item_id.clone(),
client: client.clone(),
method: None,
mode: None,
@@ -381,6 +394,7 @@ async fn inspect_socks5_udp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
parent_tool_item_id: parent_tool_item_id.clone(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Limited),
@@ -403,6 +417,7 @@ async fn inspect_socks5_udp(
protocol: NetworkProtocol::Socks5Udp,
host: host.clone(),
port,
parent_tool_item_id: parent_tool_item_id.clone(),
client_addr: client.clone(),
method: None,
command: None,
@@ -427,6 +442,7 @@ async fn inspect_socks5_udp(
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: reason.clone(),
parent_tool_item_id,
client: client.clone(),
method: None,
mode: None,