feat(core): add structured network approval plumbing and policy decision model (#11672)

### Description
#### Summary
Introduces the core plumbing required for structured network approvals

#### What changed
- Added structured network policy decision modeling in core.
- Added approval payload/context types needed for network approval
semantics.
- Wired shell/unified-exec runtime plumbing to consume structured
decisions.
- Updated related core error/event surfaces for structured handling.
- Updated protocol plumbing used by core approval flow.
- Included small CLI debug sandbox compatibility updates needed by this
layer.

#### Why
establishes the minimal backend foundation for network approvals without
yet changing high-level orchestration or TUI behavior.

#### Notes
- Behavior remains constrained by existing requirements/config gating.
- Follow-up PRs in the stack handle orchestration, UX, and app-server
integration.

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
viyatb-oai
2026-02-13 20:18:12 -08:00
committed by GitHub
Unverified
parent 854e91e422
commit b527ee2890
47 changed files with 1874 additions and 176 deletions
@@ -1438,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -3315,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -6308,6 +6343,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -2059,6 +2059,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -4207,6 +4218,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -3451,6 +3451,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -6263,6 +6274,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"NewConversationParams": {
"properties": {
"approvalPolicy": {
@@ -1438,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -3315,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -1438,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -3315,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -1438,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -3315,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
import type { ParsedCommand } from "./ParsedCommand";
export type ExecApprovalRequestEvent = {
@@ -26,6 +27,10 @@ cwd: string,
* Optional human-readable reason for the approval (e.g. retry without sandbox).
*/
reason: string | null,
/**
* Optional network context for a blocked request that can be approved.
*/
network_approval_context?: NetworkApprovalContext,
/**
* Proposed execpolicy amendment that can be applied to allow future runs.
*/
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkApprovalProtocol = "http" | "https" | "socks5_tcp" | "socks5_udp";
@@ -126,6 +126,8 @@ export type { McpToolCallEndEvent } from "./McpToolCallEndEvent";
export type { MessagePhase } from "./MessagePhase";
export type { ModeKind } from "./ModeKind";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type { NewConversationParams } from "./NewConversationParams";
export type { NewConversationResponse } from "./NewConversationResponse";
export type { ParsedCommand } from "./ParsedCommand";
@@ -206,6 +206,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
proposed_execpolicy_amendment,
parsed_cmd,
..
}) => match api_version {
ApiVersion::V1 => {
let params = ExecCommandApprovalParams {
@@ -1688,8 +1688,18 @@ impl CodexMessageProcessor {
let timeout_ms = params
.timeout_ms
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
let managed_network_requirements_enabled =
self.config.managed_network_requirements_enabled();
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec.start_proxy().await {
Some(spec) => match spec
.start_proxy(
self.config.permissions.sandbox_policy.get(),
None,
None,
managed_network_requirements_enabled,
)
.await
{
Ok(started) => Some(started),
Err(err) => {
let error = JSONRPCErrorError {
@@ -1712,6 +1722,7 @@ impl CodexMessageProcessor {
network: started_network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy),
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
justification: None,
+10 -3
View File
@@ -213,12 +213,19 @@ async fn run_command_under_sandbox(
#[cfg(not(target_os = "macos"))]
let _ = log_denials;
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
// This proxy should only live for the lifetime of the child process.
let network_proxy = match config.permissions.network.as_ref() {
Some(spec) => Some(
spec.start_proxy()
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
spec.start_proxy(
config.permissions.sandbox_policy.get(),
None,
None,
managed_network_requirements_enabled,
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
),
None => None,
};
+88 -16
View File
@@ -170,6 +170,7 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecApprovalRequestEvent;
use crate::protocol::McpServerRefreshConfig;
use crate::protocol::NetworkApprovalContext;
use crate::protocol::Op;
use crate::protocol::PlanDeltaEvent;
use crate::protocol::RateLimitSnapshot;
@@ -223,6 +224,9 @@ use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
@@ -519,7 +523,6 @@ pub(crate) struct Session {
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
}
/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub(crate) struct TurnContext {
@@ -822,6 +825,33 @@ impl Session {
}
}
async fn start_managed_network_proxy(
spec: &crate::config::NetworkProxySpec,
sandbox_policy: &SandboxPolicy,
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
managed_network_requirements_enabled: bool,
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
let network_proxy = spec
.start_proxy(
sandbox_policy,
network_policy_decider,
blocked_request_observer,
managed_network_requirements_enabled,
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?;
let session_network_proxy = {
let proxy = network_proxy.proxy();
SessionNetworkProxyRuntime {
http_addr: proxy.http_addr().to_string(),
socks_addr: proxy.socks_addr().to_string(),
admin_addr: proxy.admin_addr().to_string(),
}
};
Ok((network_proxy, session_network_proxy))
}
/// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it.
pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config {
// todo(aibrahim): store this state somewhere else so we don't need to mut config
@@ -1178,21 +1208,50 @@ impl Session {
};
session_configuration.thread_name = thread_name.clone();
let mut state = SessionState::new(session_configuration.clone());
let network_proxy =
match config.permissions.network.as_ref() {
Some(spec) => Some(spec.start_proxy().await.map_err(|err| {
anyhow::anyhow!("failed to start managed network proxy: {err}")
})?),
None => None,
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
let network_approval = Arc::new(NetworkApprovalService::default());
// The managed proxy can call back into core for allowlist-miss decisions.
let network_policy_decider_session = if managed_network_requirements_enabled {
config
.permissions
.network
.as_ref()
.map(|_| Arc::new(RwLock::new(std::sync::Weak::<Session>::new())))
} else {
None
};
let blocked_request_observer = if managed_network_requirements_enabled {
config
.permissions
.network
.as_ref()
.map(|_| build_blocked_request_observer(Arc::clone(&network_approval)))
} else {
None
};
let network_policy_decider =
network_policy_decider_session
.as_ref()
.map(|network_policy_decider_session| {
build_network_policy_decider(
Arc::clone(&network_approval),
Arc::clone(network_policy_decider_session),
)
});
let (network_proxy, session_network_proxy) =
if let Some(spec) = config.permissions.network.as_ref() {
let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy(
spec,
config.permissions.sandbox_policy.get(),
network_policy_decider.as_ref().map(Arc::clone),
blocked_request_observer.as_ref().map(Arc::clone),
managed_network_requirements_enabled,
)
.await?;
(Some(network_proxy), Some(session_network_proxy))
} else {
(None, None)
};
let session_network_proxy = network_proxy.as_ref().map(|started| {
let proxy = started.proxy();
SessionNetworkProxyRuntime {
http_addr: proxy.http_addr().to_string(),
socks_addr: proxy.socks_addr().to_string(),
admin_addr: proxy.admin_addr().to_string(),
}
});
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
@@ -1218,6 +1277,7 @@ impl Session {
file_watcher,
agent_control,
network_proxy,
network_approval: Arc::clone(&network_approval),
state_db: state_db_ctx.clone(),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
@@ -1260,6 +1320,10 @@ impl Session {
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});
if let Some(network_policy_decider_session) = network_policy_decider_session {
let mut guard = network_policy_decider_session.write().await;
*guard = Arc::downgrade(&sess);
}
// Dispatch the SessionConfiguredEvent first and then report any errors.
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
@@ -2082,7 +2146,7 @@ impl Session {
Ok(())
}
async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option<Arc<TurnContext>> {
pub(crate) async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option<Arc<TurnContext>> {
let active = self.active_turn.lock().await;
active
.as_ref()
@@ -2144,6 +2208,7 @@ impl Session {
command: Vec<String>,
cwd: PathBuf,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
) -> ReviewDecision {
// Add the tx_approve callback to the map before sending the request.
@@ -2170,6 +2235,7 @@ impl Session {
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
parsed_cmd,
});
@@ -6948,6 +7014,7 @@ mod tests {
let mut state = SessionState::new(session_configuration.clone());
mark_state_initial_context_seeded(&mut state);
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
let network_approval = Arc::new(NetworkApprovalService::default());
let file_watcher = Arc::new(FileWatcher::noop());
let services = SessionServices {
@@ -6974,6 +7041,7 @@ mod tests {
file_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
state_db: None,
model_client: ModelClient::new(
Some(auth_manager.clone()),
@@ -7094,6 +7162,7 @@ mod tests {
let mut state = SessionState::new(session_configuration.clone());
mark_state_initial_context_seeded(&mut state);
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
let network_approval = Arc::new(NetworkApprovalService::default());
let file_watcher = Arc::new(FileWatcher::noop());
let services = SessionServices {
@@ -7120,6 +7189,7 @@ mod tests {
file_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
state_db: None,
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
@@ -7882,6 +7952,7 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: Some("test".to_string()),
@@ -7895,6 +7966,7 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
arg0: None,
+2
View File
@@ -316,6 +316,7 @@ async fn handle_exec_approval(
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
..
} = event;
@@ -327,6 +328,7 @@ async fn handle_exec_approval(
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
);
let decision =
+7
View File
@@ -1950,6 +1950,13 @@ impl Config {
self.permissions.windows_sandbox_mode
};
}
pub fn managed_network_requirements_enabled(&self) -> bool {
self.config_layer_stack
.requirements_toml()
.network
.is_some()
}
}
pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool {
+33 -8
View File
@@ -1,8 +1,11 @@
use crate::config;
use crate::config_loader::NetworkConstraints;
use async_trait::async_trait;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
@@ -11,6 +14,7 @@ use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_network_proxy::host_and_port_from_network_addr;
use codex_network_proxy::validate_policy_against_constraints;
use codex_protocol::protocol::SandboxPolicy;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -92,20 +96,41 @@ impl NetworkProxySpec {
})
}
pub async fn start_proxy(&self) -> std::io::Result<StartedNetworkProxy> {
pub async fn start_proxy(
&self,
sandbox_policy: &SandboxPolicy,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
enable_network_approval_flow: bool,
) -> std::io::Result<StartedNetworkProxy> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
let state = NetworkProxyState::with_reloader(state, reloader);
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.build()
.await
.map_err(|err| {
std::io::Error::other(format!("failed to build network proxy: {err}"))
})?;
let mut builder = NetworkProxy::builder().state(Arc::new(state));
if enable_network_approval_flow
&& matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
{
builder = match policy_decider {
Some(policy_decider) => builder.policy_decider_arc(policy_decider),
None => builder.policy_decider(|_request| async {
// In restricted sandbox modes, allowlist misses should ask for
// explicit network approval instead of hard-denying.
NetworkDecision::ask("not_allowed")
}),
};
}
if let Some(blocked_request_observer) = blocked_request_observer {
builder = builder.blocked_request_observer_arc(blocked_request_observer);
}
let proxy = builder.build().await.map_err(|err| {
std::io::Error::other(format!("failed to build network proxy: {err}"))
})?;
let handle = proxy
.run()
.await
+10 -2
View File
@@ -1,4 +1,5 @@
use crate::exec::ExecToolCallOutput;
use crate::network_policy_decision::NetworkPolicyDecisionPayload;
use crate::token_data::KnownPlan;
use crate::token_data::PlanType;
use crate::truncate::TruncationPolicy;
@@ -31,7 +32,10 @@ pub enum SandboxErr {
"sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}",
.output.exit_code, .output.stdout.text, .output.stderr.text
)]
Denied { output: Box<ExecToolCallOutput> },
Denied {
output: Box<ExecToolCallOutput>,
network_policy_decision: Option<NetworkPolicyDecisionPayload>,
},
/// Error from linux seccomp filter setup
#[cfg(target_os = "linux")]
@@ -616,7 +620,7 @@ impl CodexErr {
pub fn get_error_message_ui(e: &CodexErr) -> String {
let message = match e {
CodexErr::Sandbox(SandboxErr::Denied { output }) => {
CodexErr::Sandbox(SandboxErr::Denied { output, .. }) => {
let aggregated = output.aggregated_output.text.trim();
if !aggregated.is_empty() {
output.aggregated_output.text.clone()
@@ -736,6 +740,7 @@ mod tests {
};
let err = CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
});
assert_eq!(get_error_message_ui(&err), "aggregate detail");
}
@@ -752,6 +757,7 @@ mod tests {
};
let err = CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
});
assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail");
}
@@ -768,6 +774,7 @@ mod tests {
};
let err = CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
});
assert_eq!(get_error_message_ui(&err), "stdout only");
}
@@ -811,6 +818,7 @@ mod tests {
};
let err = CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
});
assert_eq!(
get_error_message_ui(&err),
+56 -5
View File
@@ -15,6 +15,7 @@ use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::error::CodexErr;
use crate::error::Result;
@@ -66,6 +67,7 @@ pub struct ExecParams {
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub network_attempt_id: Option<Uuid>,
pub sandbox_permissions: SandboxPermissions,
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub justification: Option<String>,
@@ -184,13 +186,15 @@ pub async fn process_exec_tool_call(
mut env,
expiration,
network,
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
arg0: _,
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -238,6 +242,7 @@ pub(crate) async fn execute_exec_env(
cwd,
env,
network,
network_attempt_id,
expiration,
sandbox,
windows_sandbox_level,
@@ -246,12 +251,18 @@ pub(crate) async fn execute_exec_env(
arg0,
} = env;
let network_attempt_id = match network_attempt_id.as_deref() {
Some(attempt_id) => Uuid::parse_str(attempt_id).ok(),
None => network.as_ref().map(|_| Uuid::new_v4()),
};
let params = ExecParams {
command,
cwd,
expiration,
env,
network,
network: network.clone(),
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
@@ -345,12 +356,14 @@ async fn exec_windows_sandbox(
cwd,
mut env,
network,
network_attempt_id,
expiration,
windows_sandbox_level,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
}
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
@@ -490,6 +503,7 @@ fn finalize_exec_result(
if is_likely_sandbox_denied(sandbox_type, &exec_output) {
return Err(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(exec_output),
network_policy_decision: None,
}));
}
@@ -701,13 +715,19 @@ async fn exec(
let ExecParams {
command,
cwd,
env,
mut env,
network,
network_attempt_id,
arg0,
expiration,
windows_sandbox_level: _,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -722,7 +742,10 @@ async fn exec(
arg0: arg0_ref,
cwd,
sandbox_policy,
network: network.as_ref(),
// The environment already has attempt-scoped proxy settings from
// apply_to_env_for_attempt above. Passing network here would reapply
// non-attempt proxy vars and drop attempt correlation metadata.
network: None,
stdio_policy: StdioPolicy::RedirectForShellTool,
env,
})
@@ -951,6 +974,17 @@ mod tests {
assert!(!is_likely_sandbox_denied(SandboxType::None, &output));
}
#[test]
fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() {
let output = make_exec_output(
0,
"",
"",
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#,
);
assert!(!is_likely_sandbox_denied(SandboxType::None, &output));
}
#[test]
fn sandbox_detection_uses_aggregated_output() {
let output = make_exec_output(
@@ -965,6 +999,21 @@ mod tests {
));
}
#[test]
fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() {
let output = make_exec_output(
0,
"",
"",
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#,
);
assert!(!is_likely_sandbox_denied(
SandboxType::LinuxSeccomp,
&output
));
}
#[tokio::test]
async fn read_capped_limits_retained_bytes() {
let (mut writer, reader) = tokio::io::duplex(1024);
@@ -1088,6 +1137,7 @@ mod tests {
expiration: 500.into(),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -1141,6 +1191,7 @@ mod tests {
expiration: ExecExpiration::Cancellation(cancel_token),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
+1
View File
@@ -41,6 +41,7 @@ pub mod landlock;
pub mod mcp;
mod mcp_connection_manager;
pub mod models_manager;
mod network_policy_decision;
pub mod network_proxy_loader;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
@@ -0,0 +1,254 @@
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::NetworkDecisionSource;
use codex_network_proxy::NetworkPolicyDecision;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NetworkPolicyDecisionPayload {
pub decision: NetworkPolicyDecision,
pub source: NetworkDecisionSource,
#[serde(default)]
pub protocol: Option<NetworkApprovalProtocol>,
pub host: Option<String>,
pub reason: Option<String>,
pub port: Option<u16>,
}
impl NetworkPolicyDecisionPayload {
pub(crate) fn is_ask_from_decider(&self) -> bool {
self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider
}
}
fn parse_network_policy_decision(value: &str) -> Option<NetworkPolicyDecision> {
match value {
"deny" => Some(NetworkPolicyDecision::Deny),
"ask" => Some(NetworkPolicyDecision::Ask),
_ => None,
}
}
pub(crate) fn network_approval_context_from_payload(
payload: &NetworkPolicyDecisionPayload,
) -> Option<NetworkApprovalContext> {
if !payload.is_ask_from_decider() {
return None;
}
let protocol = payload.protocol?;
let host = payload.host.as_deref()?.trim();
if host.is_empty() {
return None;
}
Some(NetworkApprovalContext {
host: host.to_string(),
protocol,
})
}
pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option<String> {
let decision = blocked
.decision
.as_deref()
.and_then(parse_network_policy_decision);
if decision != Some(NetworkPolicyDecision::Deny) {
return None;
}
let host = blocked.host.trim();
if host.is_empty() {
return Some("Network access was blocked by policy.".to_string());
}
let detail = match blocked.reason.as_str() {
"denied" => "domain is explicitly denied by policy and cannot be approved from this prompt",
"not_allowed" => "domain is not on the allowlist for the current sandbox mode",
"not_allowed_local" => "local/private network addresses are blocked by policy",
"method_not_allowed" => "request method is blocked by the current network mode",
"proxy_disabled" => "managed network proxy is disabled",
_ => "request is blocked by network policy",
};
Some(format!(
"Network access to \"{host}\" was blocked: {detail}."
))
}
#[cfg(test)]
mod tests {
use super::*;
use codex_network_proxy::BlockedRequest;
use pretty_assertions::assert_eq;
#[test]
fn network_approval_context_requires_ask_from_decider() {
let payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Deny,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Https),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(443),
};
assert_eq!(network_approval_context_from_payload(&payload), None);
}
#[test]
fn network_approval_context_maps_http_https_and_socks_protocols() {
let http_payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Ask,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Http),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(80),
};
assert_eq!(
network_approval_context_from_payload(&http_payload),
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Http,
})
);
let https_payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Ask,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Https),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(443),
};
assert_eq!(
network_approval_context_from_payload(&https_payload),
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
})
);
let http_connect_payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Ask,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Https),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(443),
};
assert_eq!(
network_approval_context_from_payload(&http_connect_payload),
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
})
);
let socks5_tcp_payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Ask,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Socks5Tcp),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(443),
};
assert_eq!(
network_approval_context_from_payload(&socks5_tcp_payload),
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Socks5Tcp,
})
);
let socks5_udp_payload = NetworkPolicyDecisionPayload {
decision: NetworkPolicyDecision::Ask,
source: NetworkDecisionSource::Decider,
protocol: Some(NetworkApprovalProtocol::Socks5Udp),
host: Some("example.com".to_string()),
reason: Some("not_allowed".to_string()),
port: Some(443),
};
assert_eq!(
network_approval_context_from_payload(&socks5_udp_payload),
Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Socks5Udp,
})
);
}
#[test]
fn network_policy_decision_payload_deserializes_proxy_protocol_aliases() {
let payload: NetworkPolicyDecisionPayload = serde_json::from_str(
r#"{
"decision":"ask",
"source":"decider",
"protocol":"https_connect",
"host":"example.com",
"reason":"not_allowed",
"port":443
}"#,
)
.expect("payload should deserialize");
assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https));
let payload: NetworkPolicyDecisionPayload = serde_json::from_str(
r#"{
"decision":"ask",
"source":"decider",
"protocol":"http-connect",
"host":"example.com",
"reason":"not_allowed",
"port":443
}"#,
)
.expect("payload should deserialize");
assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https));
}
#[test]
fn denied_network_policy_message_requires_deny_decision() {
let blocked = BlockedRequest {
host: "example.com".to_string(),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
timestamp: 0,
};
assert_eq!(denied_network_policy_message(&blocked), None);
}
#[test]
fn denied_network_policy_message_for_denylist_block_is_explicit() {
let blocked = BlockedRequest {
host: "example.com".to_string(),
reason: "denied".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
timestamp: 0,
};
assert_eq!(
denied_network_policy_message(&blocked),
Some(
"Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string()
)
);
}
}
+2
View File
@@ -46,6 +46,7 @@ pub struct ExecRequest {
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub network_attempt_id: Option<String>,
pub expiration: ExecExpiration,
pub sandbox: SandboxType,
pub windows_sandbox_level: WindowsSandboxLevel,
@@ -214,6 +215,7 @@ impl SandboxManager {
cwd: spec.cwd,
env,
network: network.cloned(),
network_attempt_id: None,
expiration: spec.expiration,
sandbox,
windows_sandbox_level,
+2
View File
@@ -12,6 +12,7 @@ use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::skills::SkillsManager;
use crate::state_db::StateDbHandle;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_hooks::Hooks;
@@ -40,6 +41,7 @@ pub(crate) struct SessionServices {
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,
pub(crate) network_approval: Arc<NetworkApprovalService>,
pub(crate) state_db: Option<StateDbHandle>,
/// Session-scoped model client shared across turns.
pub(crate) model_client: ModelClient,
+1
View File
@@ -150,6 +150,7 @@ pub(crate) async fn execute_user_shell_command(
Some(session.conversation_id),
),
network: turn_context.network.clone(),
network_attempt_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(),
+1 -1
View File
@@ -314,7 +314,7 @@ impl ToolEmitter {
(event, result)
}
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output })))
| Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => {
| Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. }))) => {
let response = self.format_exec_output_for_model(&output, ctx);
let event = ToolEventStage::Failure(ToolEventFailure::Output(*output));
let result = Err(FunctionCallError::RespondToModel(response));
@@ -143,10 +143,12 @@ impl ToolHandler for ApplyPatchHandler {
turn: turn.as_ref(),
call_id: call_id.clone(),
tool_name: tool_name.to_string(),
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
.await;
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
@@ -232,10 +234,12 @@ pub(crate) async fn intercept_apply_patch(
turn,
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy)
.await;
.await
.map(|result| result.output);
let event_ctx =
ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied());
let content = emitter.finish(event_ctx, out).await?;
+5 -1
View File
@@ -54,6 +54,7 @@ impl ShellHandler {
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
network_attempt_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -83,6 +84,7 @@ impl ShellCommandHandler {
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
network_attempt_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -325,10 +327,12 @@ impl ShellHandler {
turn: turn.as_ref(),
call_id: call_id.clone(),
tool_name,
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
.await;
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
let content = emitter.finish(event_ctx, out).await?;
Ok(ToolOutput::Function {
+1
View File
@@ -2,6 +2,7 @@ pub mod context;
pub mod events;
pub(crate) mod handlers;
pub mod js_repl;
pub(crate) mod network_approval;
pub mod orchestrator;
pub mod parallel;
pub mod registry;
+675
View File
@@ -0,0 +1,675 @@
use crate::codex::Session;
use crate::network_policy_decision::denied_network_policy_message;
use crate::tools::sandboxing::ToolError;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkPolicyRequest;
use codex_network_proxy::NetworkProtocol;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::protocol::ReviewDecision;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NetworkApprovalMode {
Immediate,
Deferred,
}
#[derive(Clone, Debug)]
pub(crate) struct NetworkApprovalSpec {
pub command: Vec<String>,
pub cwd: PathBuf,
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
}
#[derive(Clone, Debug)]
pub(crate) struct DeferredNetworkApproval {
attempt_id: String,
}
impl DeferredNetworkApproval {
pub(crate) fn attempt_id(&self) -> &str {
&self.attempt_id
}
}
#[derive(Debug)]
pub(crate) struct ActiveNetworkApproval {
attempt_id: Option<String>,
mode: NetworkApprovalMode,
}
impl ActiveNetworkApproval {
pub(crate) fn attempt_id(&self) -> Option<&str> {
self.attempt_id.as_deref()
}
pub(crate) fn mode(&self) -> NetworkApprovalMode {
self.mode
}
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
match (self.mode, self.attempt_id) {
(NetworkApprovalMode::Deferred, Some(attempt_id)) => {
Some(DeferredNetworkApproval { attempt_id })
}
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum NetworkApprovalOutcome {
DeniedByUser,
DeniedByPolicy(String),
}
struct NetworkApprovalAttempt {
turn_id: String,
call_id: String,
command: Vec<String>,
cwd: PathBuf,
approved_hosts: Mutex<HashSet<String>>,
outcome: Mutex<Option<NetworkApprovalOutcome>>,
}
pub(crate) struct NetworkApprovalService {
attempts: Mutex<HashMap<String, Arc<NetworkApprovalAttempt>>>,
session_approved_hosts: Mutex<HashSet<String>>,
}
impl Default for NetworkApprovalService {
fn default() -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
session_approved_hosts: Mutex::new(HashSet::new()),
}
}
}
impl NetworkApprovalService {
pub(crate) async fn register_attempt(
&self,
attempt_id: String,
turn_id: String,
call_id: String,
command: Vec<String>,
cwd: PathBuf,
) {
let mut attempts = self.attempts.lock().await;
attempts.insert(
attempt_id,
Arc::new(NetworkApprovalAttempt {
turn_id,
call_id,
command,
cwd,
approved_hosts: Mutex::new(HashSet::new()),
outcome: Mutex::new(None),
}),
);
}
pub(crate) async fn unregister_attempt(&self, attempt_id: &str) {
let mut attempts = self.attempts.lock().await;
attempts.remove(attempt_id);
}
pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option<NetworkApprovalOutcome> {
let attempt = {
let attempts = self.attempts.lock().await;
attempts.get(attempt_id).cloned()
}?;
let mut outcome = attempt.outcome.lock().await;
outcome.take()
}
pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool {
let attempt = {
let attempts = self.attempts.lock().await;
attempts.get(attempt_id).cloned()
};
let Some(attempt) = attempt else {
return false;
};
let mut outcome = attempt.outcome.lock().await;
if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) {
outcome.take();
return true;
}
false
}
async fn resolve_attempt_for_request(
&self,
request: &NetworkPolicyRequest,
) -> Option<Arc<NetworkApprovalAttempt>> {
let attempts = self.attempts.lock().await;
if let Some(attempt_id) = request.attempt_id.as_deref() {
if let Some(attempt) = attempts.get(attempt_id).cloned() {
return Some(attempt);
}
return None;
}
if attempts.len() == 1 {
return attempts.values().next().cloned();
}
None
}
async fn resolve_attempt_for_blocked_request(
&self,
blocked: &BlockedRequest,
) -> Option<Arc<NetworkApprovalAttempt>> {
let attempts = self.attempts.lock().await;
if let Some(attempt_id) = blocked.attempt_id.as_deref() {
if let Some(attempt) = attempts.get(attempt_id).cloned() {
return Some(attempt);
}
return None;
}
if attempts.len() == 1 {
return attempts.values().next().cloned();
}
None
}
pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {
let Some(message) = denied_network_policy_message(&blocked) else {
return;
};
let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else {
return;
};
let mut outcome = attempt.outcome.lock().await;
if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) {
return;
}
*outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message));
}
pub(crate) async fn handle_inline_policy_request(
&self,
session: &Session,
request: NetworkPolicyRequest,
) -> NetworkDecision {
const REASON_NOT_ALLOWED: &str = "not_allowed";
{
let approved_hosts = self.session_approved_hosts.lock().await;
if approved_hosts.contains(request.host.as_str()) {
return NetworkDecision::Allow;
}
}
let Some(attempt) = self.resolve_attempt_for_request(&request).await else {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
{
let approved_hosts = attempt.approved_hosts.lock().await;
if approved_hosts.contains(request.host.as_str()) {
return NetworkDecision::Allow;
}
}
let protocol = match request.protocol {
NetworkProtocol::Http => NetworkApprovalProtocol::Http,
NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https,
NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp,
NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,
};
let Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
let approval_decision = session
.request_command_approval(
turn_context.as_ref(),
attempt.call_id.clone(),
attempt.command.clone(),
attempt.cwd.clone(),
Some(format!(
"Network access to \"{}\" is blocked by policy.",
request.host
)),
Some(NetworkApprovalContext {
host: request.host.clone(),
protocol,
}),
None,
)
.await;
match approval_decision {
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
let mut approved_hosts = attempt.approved_hosts.lock().await;
approved_hosts.insert(request.host);
NetworkDecision::Allow
}
ReviewDecision::ApprovedForSession => {
let mut approved_hosts = self.session_approved_hosts.lock().await;
approved_hosts.insert(request.host);
NetworkDecision::Allow
}
ReviewDecision::Denied | ReviewDecision::Abort => {
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
NetworkDecision::deny(REASON_NOT_ALLOWED)
}
}
}
}
pub(crate) fn build_blocked_request_observer(
network_approval: Arc<NetworkApprovalService>,
) -> Arc<dyn BlockedRequestObserver> {
Arc::new(move |blocked: BlockedRequest| {
let network_approval = Arc::clone(&network_approval);
async move {
network_approval.record_blocked_request(blocked).await;
}
})
}
pub(crate) fn build_network_policy_decider(
network_approval: Arc<NetworkApprovalService>,
network_policy_decider_session: Arc<RwLock<std::sync::Weak<Session>>>,
) -> Arc<dyn NetworkPolicyDecider> {
Arc::new(move |request: NetworkPolicyRequest| {
let network_approval = Arc::clone(&network_approval);
let network_policy_decider_session = Arc::clone(&network_policy_decider_session);
async move {
let Some(session) = network_policy_decider_session.read().await.upgrade() else {
return NetworkDecision::ask("not_allowed");
};
network_approval
.handle_inline_policy_request(session.as_ref(), request)
.await
}
})
}
pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
call_id: &str,
has_managed_network_requirements: bool,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
let spec = spec?;
if !has_managed_network_requirements || spec.network.is_none() {
return None;
}
let attempt_id = Uuid::new_v4().to_string();
session
.services
.network_approval
.register_attempt(
attempt_id.clone(),
turn_id.to_string(),
call_id.to_string(),
spec.command,
spec.cwd,
)
.await;
Some(ActiveNetworkApproval {
attempt_id: Some(attempt_id),
mode: spec.mode,
})
}
pub(crate) async fn finish_immediate_network_approval(
session: &Session,
active: ActiveNetworkApproval,
) -> Result<(), ToolError> {
let Some(attempt_id) = active.attempt_id.as_deref() else {
return Ok(());
};
let approval_outcome = session
.services
.network_approval
.take_outcome(attempt_id)
.await;
session
.services
.network_approval
.unregister_attempt(attempt_id)
.await;
match approval_outcome {
Some(NetworkApprovalOutcome::DeniedByUser) => {
Err(ToolError::Rejected("rejected by user".to_string()))
}
Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Err(ToolError::Rejected(message)),
None => Ok(()),
}
}
pub(crate) async fn deferred_rejection_message(
session: &Session,
deferred: &DeferredNetworkApproval,
) -> Option<String> {
match session
.services
.network_approval
.take_outcome(deferred.attempt_id())
.await
{
Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()),
Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message),
None => None,
}
}
pub(crate) async fn finish_deferred_network_approval(
session: &Session,
deferred: Option<DeferredNetworkApproval>,
) {
let Some(deferred) = deferred else {
return;
};
session
.services
.network_approval
.unregister_attempt(deferred.attempt_id())
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use codex_network_proxy::BlockedRequestArgs;
use codex_network_proxy::NetworkPolicyRequestArgs;
use pretty_assertions::assert_eq;
fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest {
NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: host.to_string(),
port: 80,
client_addr: None,
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: attempt_id.map(ToString::to_string),
})
}
#[tokio::test]
async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", None))
.await
.expect("single active attempt should be used as fallback");
assert_eq!(resolved.call_id, "call-1");
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_exact_attempt_match() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
service
.register_attempt(
"attempt-2".to_string(),
"turn-2".to_string(),
"call-2".to_string(),
vec!["curl".to_string(), "openai.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2")))
.await
.expect("attempt-2 should resolve");
assert_eq!(resolved.call_id, "call-2");
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown")))
.await;
assert!(resolved.is_none());
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_none_when_ambiguous() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
service
.register_attempt(
"attempt-2".to_string(),
"turn-2".to_string(),
"call-2".to_string(),
vec!["curl".to_string(), "robinhood.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", None))
.await;
assert!(resolved.is_none());
}
#[tokio::test]
async fn take_outcome_clears_stored_value() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
}
assert_eq!(
service.take_outcome("attempt-1").await,
Some(NetworkApprovalOutcome::DeniedByUser)
);
assert_eq!(service.take_outcome("attempt-1").await, None);
}
#[tokio::test]
async fn take_user_denial_outcome_preserves_policy_denial() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(
"policy denied".to_string(),
));
}
assert!(!service.take_user_denial_outcome("attempt-1").await);
assert_eq!(
service.take_outcome("attempt-1").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"policy denied".to_string(),
))
);
}
#[tokio::test]
async fn record_blocked_request_stores_policy_denial_outcome() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
service
.record_blocked_request(BlockedRequest::new(BlockedRequestArgs {
host: "example.com".to_string(),
reason: "denied".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
}))
.await;
let outcome = service
.take_outcome("attempt-1")
.await
.expect("outcome should be recorded");
match outcome {
NetworkApprovalOutcome::DeniedByPolicy(message) => {
assert_eq!(
message,
"Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string()
);
}
NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"),
}
}
#[tokio::test]
async fn record_blocked_request_does_not_override_user_denial() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
}
service
.record_blocked_request(BlockedRequest::new(BlockedRequestArgs {
host: "example.com".to_string(),
reason: "denied".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
}))
.await;
assert_eq!(
service.take_outcome("attempt-1").await,
Some(NetworkApprovalOutcome::DeniedByUser)
);
}
}
+142 -12
View File
@@ -10,7 +10,13 @@ use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::exec::ExecToolCallOutput;
use crate::features::Feature;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::sandboxing::SandboxManager;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
@@ -27,6 +33,11 @@ pub(crate) struct ToolOrchestrator {
sandbox: SandboxManager,
}
pub(crate) struct OrchestratorRunResult<Out> {
pub output: Out,
pub deferred_network_approval: Option<DeferredNetworkApproval>,
}
impl ToolOrchestrator {
pub fn new() -> Self {
Self {
@@ -34,6 +45,60 @@ impl ToolOrchestrator {
}
}
async fn run_attempt<Rq, Out, T>(
tool: &mut T,
req: &Rq,
tool_ctx: &ToolCtx<'_>,
attempt: &SandboxAttempt<'_>,
has_managed_network_requirements: bool,
) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
where
T: ToolRuntime<Rq, Out>,
{
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,
turn: tool_ctx.turn,
call_id: tool_ctx.call_id.clone(),
tool_name: tool_ctx.tool_name.clone(),
network_attempt_id: network_approval.as_ref().and_then(|network_approval| {
network_approval.attempt_id().map(ToString::to_string)
}),
};
let run_result = tool.run(req, attempt, &attempt_tool_ctx).await;
let Some(network_approval) = network_approval else {
return (run_result, None);
};
match network_approval.mode() {
NetworkApprovalMode::Immediate => {
let finalize_result =
finish_immediate_network_approval(tool_ctx.session, network_approval).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
(run_result, None)
}
NetworkApprovalMode::Deferred => {
let deferred = network_approval.into_deferred();
if run_result.is_err() {
finish_deferred_network_approval(tool_ctx.session, deferred).await;
return (run_result, None);
}
(run_result, deferred)
}
}
}
pub async fn run<Rq, Out, T>(
&mut self,
tool: &mut T,
@@ -41,7 +106,7 @@ impl ToolOrchestrator {
tool_ctx: &ToolCtx<'_>,
turn_ctx: &crate::codex::TurnContext,
approval_policy: AskForApproval,
) -> Result<Out, ToolError>
) -> Result<OrchestratorRunResult<Out>, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
@@ -70,6 +135,7 @@ impl ToolOrchestrator {
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: reason,
network_approval_context: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
@@ -118,33 +184,86 @@ impl ToolOrchestrator {
windows_sandbox_level: turn_ctx.windows_sandbox_level,
};
match tool.run(req, &initial_attempt, tool_ctx).await {
let (first_result, first_deferred_network_approval) = Self::run_attempt(
tool,
req,
tool_ctx,
&initial_attempt,
has_managed_network_requirements,
)
.await;
match first_result {
Ok(out) => {
// We have a successful initial result
Ok(out)
Ok(OrchestratorRunResult {
output: out,
deferred_network_approval: first_deferred_network_approval,
})
}
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => {
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
}))) => {
let network_approval_context = if has_managed_network_requirements {
network_policy_decision
.as_ref()
.and_then(network_approval_context_from_payload)
} else {
None
};
if network_policy_decision.is_some() && network_approval_context.is_none() {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
if !tool.escalate_on_failure() {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
// Under `Never` or `OnRequest`, do not retry without sandbox; surface a concise
// sandbox denial that preserves the original output.
if !tool.wants_no_sandbox_approval(approval_policy) {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
})));
let allow_on_request_network_prompt =
matches!(approval_policy, AskForApproval::OnRequest)
&& network_approval_context.is_some()
&& matches!(
default_exec_approval_requirement(
approval_policy,
&turn_ctx.sandbox_policy
),
ExecApprovalRequirement::NeedsApproval { .. }
);
if !allow_on_request_network_prompt {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
}
let retry_reason =
if let Some(network_approval_context) = network_approval_context.as_ref() {
format!(
"Network access to \"{}\" is blocked by policy.",
network_approval_context.host
)
} else {
build_denial_reason_from_output(output.as_ref())
};
// Ask for approval before retrying with the escalated sandbox.
if !tool.should_bypass_approval(approval_policy, already_approved) {
let reason_msg = build_denial_reason_from_output(output.as_ref());
let bypass_retry_approval = tool
.should_bypass_approval(approval_policy, already_approved)
&& network_approval_context.is_none();
if !bypass_retry_approval {
let approval_ctx = ApprovalCtx {
session: tool_ctx.session,
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: Some(reason_msg),
retry_reason: Some(retry_reason),
network_approval_context: network_approval_context.clone(),
};
let decision = tool.start_approval_async(req, approval_ctx).await;
@@ -172,9 +291,20 @@ impl ToolOrchestrator {
};
// Second attempt.
(*tool).run(req, &escalated_attempt, tool_ctx).await
let (retry_result, retry_deferred_network_approval) = Self::run_attempt(
tool,
req,
tool_ctx,
&escalated_attempt,
has_managed_network_requirements,
)
.await;
retry_result.map(|output| OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
})
}
other => other,
Err(err) => Err(err),
}
}
}
+19 -1
View File
@@ -11,6 +11,8 @@ use crate::powershell::prefix_powershell_script_with_utf8;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
@@ -109,6 +111,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
command,
cwd,
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
@@ -141,6 +144,20 @@ impl Approvable<ShellRequest> for ShellRuntime {
}
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
fn network_approval_spec(
&self,
req: &ShellRequest,
_ctx: &ToolCtx<'_>,
) -> Option<NetworkApprovalSpec> {
req.network.as_ref()?;
Some(NetworkApprovalSpec {
command: req.command.clone(),
cwd: req.cwd.clone(),
network: req.network.clone(),
mode: NetworkApprovalMode::Immediate,
})
}
async fn run(
&mut self,
req: &ShellRequest,
@@ -167,9 +184,10 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.sandbox_permissions,
req.justification.clone(),
)?;
let env = attempt
let mut env = attempt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
env.network_attempt_id = ctx.network_attempt_id.clone();
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
@@ -12,6 +12,8 @@ use crate::features::Feature;
use crate::powershell::prefix_powershell_script_with_utf8;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_command_spec;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
@@ -110,6 +112,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
command,
cwd,
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
@@ -145,6 +148,20 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
}
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRuntime<'a> {
fn network_approval_spec(
&self,
req: &UnifiedExecRequest,
_ctx: &ToolCtx<'_>,
) -> Option<NetworkApprovalSpec> {
req.network.as_ref()?;
Some(NetworkApprovalSpec {
command: req.command.clone(),
cwd: req.cwd.clone(),
network: req.network.clone(),
mode: NetworkApprovalMode::Deferred,
})
}
async fn run(
&mut self,
req: &UnifiedExecRequest,
@@ -165,7 +182,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_attempt(&mut env, ctx.network_attempt_id.as_deref());
}
let spec = build_command_spec(
&command,
@@ -186,6 +203,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
UnifiedExecError::SandboxDenied { output, .. } => {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
}))
}
other => ToolError::Rejected(other.to_string()),
+8
View File
@@ -12,8 +12,10 @@ use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxTransformError;
use crate::state::SessionServices;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use std::collections::HashMap;
@@ -110,6 +112,7 @@ pub(crate) struct ApprovalCtx<'a> {
pub turn: &'a TurnContext,
pub call_id: &'a str,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
}
// Specifies what tool orchestrator should do with a given tool call.
@@ -252,6 +255,7 @@ pub(crate) struct ToolCtx<'a> {
pub turn: &'a TurnContext,
pub call_id: String,
pub tool_name: String,
pub network_attempt_id: Option<String>,
}
#[derive(Debug)]
@@ -261,6 +265,10 @@ pub(crate) enum ToolError {
}
pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx<'_>) -> Option<NetworkApprovalSpec> {
None
}
async fn run(
&mut self,
req: &Req,
@@ -139,6 +139,43 @@ pub(crate) fn spawn_exit_watcher(
});
}
pub(crate) fn spawn_network_denial_watcher(
process: Arc<UnifiedExecProcess>,
session: Arc<Session>,
process_id: String,
network_attempt_id: String,
) {
let exit_token = process.cancellation_token();
tokio::spawn(async move {
let mut poll = tokio::time::interval(Duration::from_millis(100));
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = exit_token.cancelled() => {
break;
}
_ = poll.tick() => {
if session
.services
.network_approval
.take_user_denial_outcome(&network_attempt_id)
.await
{
process.terminate();
session
.services
.unified_exec_manager
.release_process_id(&process_id)
.await;
break;
}
}
}
}
});
}
async fn process_chunk(
pending: &mut Vec<u8>,
transcript: &Arc<Mutex<HeadTailBuffer>>,
+3
View File
@@ -25,6 +25,7 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Weak;
use std::time::Duration;
use codex_network_proxy::NetworkProxy;
@@ -144,6 +145,8 @@ struct ProcessEntry {
process_id: String,
command: Vec<String>,
tty: bool,
network_attempt_id: Option<String>,
session: Weak<Session>,
last_used: tokio::time::Instant,
}
+121 -38
View File
@@ -19,6 +19,9 @@ use crate::sandboxing::ExecRequest;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventStage;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::deferred_rejection_message;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
@@ -40,6 +43,7 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::WriteStdinRequest;
use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec;
use crate::unified_exec::async_watcher::spawn_exit_watcher;
use crate::unified_exec::async_watcher::spawn_network_denial_watcher;
use crate::unified_exec::async_watcher::start_streaming_output;
use crate::unified_exec::clamp_yield_time;
use crate::unified_exec::generate_chunk_id;
@@ -130,8 +134,25 @@ impl UnifiedExecProcessManager {
}
pub(crate) async fn release_process_id(&self, process_id: &str) {
let mut store = self.process_store.lock().await;
store.remove(process_id);
let removed = {
let mut store = self.process_store.lock().await;
store.remove(process_id)
};
if let Some(entry) = removed {
Self::unregister_network_attempt_for_entry(&entry).await;
}
}
async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) {
if let Some(attempt_id) = entry.network_attempt_id.as_deref()
&& let Some(session) = entry.session.upgrade()
{
session
.services
.network_approval
.unregister_attempt(attempt_id)
.await;
}
}
pub(crate) async fn exec_command(
@@ -143,13 +164,14 @@ impl UnifiedExecProcessManager {
.workdir
.clone()
.unwrap_or_else(|| context.turn.cwd.clone());
let process = self
.open_session_with_sandbox(&request, cwd.clone(), context)
.await;
let process = match process {
Ok(process) => Arc::new(process),
let (process, mut deferred_network_approval) = match process {
Ok((process, deferred_network_approval)) => {
(Arc::new(process), deferred_network_approval)
}
Err(err) => {
self.release_process_id(&request.process_id).await;
return Err(err);
@@ -172,7 +194,6 @@ impl UnifiedExecProcessManager {
emitter.emit(event_ctx, ToolEventStage::Begin).await;
start_streaming_output(&process, context, Arc::clone(&transcript));
let max_tokens = resolve_max_tokens(request.max_output_tokens);
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
@@ -205,6 +226,7 @@ impl UnifiedExecProcessManager {
let has_exited = process.has_exited() || exit_code.is_some();
let chunk_id = generate_chunk_id();
let process_id = request.process_id.clone();
if has_exited {
// Shortlived command: emit ExecCommandEnd immediately using the
// same helper as the background watcher, so all end events share
@@ -215,7 +237,7 @@ impl UnifiedExecProcessManager {
Arc::clone(&context.turn),
context.call_id.clone(),
request.command.clone(),
cwd,
cwd.clone(),
Some(process_id),
Arc::clone(&transcript),
output.clone(),
@@ -225,12 +247,45 @@ impl UnifiedExecProcessManager {
.await;
self.release_process_id(&request.process_id).await;
if let Some(deferred) = deferred_network_approval.as_ref()
&& let Some(message) =
deferred_rejection_message(context.session.as_ref(), deferred).await
{
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
)
.await;
return Err(UnifiedExecError::create_process(message));
}
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
)
.await;
process.check_for_sandbox_denial_with_text(&text).await?;
} else {
if let Some(deferred) = deferred_network_approval.as_ref()
&& let Some(message) =
deferred_rejection_message(context.session.as_ref(), deferred).await
{
process.terminate();
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
)
.await;
self.release_process_id(&request.process_id).await;
return Err(UnifiedExecError::create_process(message));
}
// Longlived command: persist the process so write_stdin can reuse
// it, and register a background watcher that will emit
// ExecCommandEnd when the PTY eventually exits (even if no further
// tool calls are made).
let network_attempt_id = deferred_network_approval
.as_ref()
.map(|deferred| deferred.attempt_id().to_string());
self.store_process(
Arc::clone(&process),
context,
@@ -239,6 +294,7 @@ impl UnifiedExecProcessManager {
start,
process_id,
request.tty,
network_attempt_id,
Arc::clone(&transcript),
)
.await;
@@ -358,29 +414,35 @@ impl UnifiedExecProcessManager {
}
async fn refresh_process_state(&self, process_id: &str) -> ProcessStatus {
let mut store = self.process_store.lock().await;
let Some(entry) = store.processes.get(process_id) else {
return ProcessStatus::Unknown;
};
let exit_code = entry.process.exit_code();
let process_id = entry.process_id.clone();
if entry.process.has_exited() {
let Some(entry) = store.remove(&process_id) else {
let status = {
let mut store = self.process_store.lock().await;
let Some(entry) = store.processes.get(process_id) else {
return ProcessStatus::Unknown;
};
ProcessStatus::Exited {
exit_code,
entry: Box::new(entry),
}
} else {
ProcessStatus::Alive {
exit_code,
call_id: entry.call_id.clone(),
process_id,
let exit_code = entry.process.exit_code();
let process_id = entry.process_id.clone();
if entry.process.has_exited() {
let Some(entry) = store.remove(&process_id) else {
return ProcessStatus::Unknown;
};
ProcessStatus::Exited {
exit_code,
entry: Box::new(entry),
}
} else {
ProcessStatus::Alive {
exit_code,
call_id: entry.call_id.clone(),
process_id,
}
}
};
if let ProcessStatus::Exited { entry, .. } = &status {
Self::unregister_network_attempt_for_entry(entry).await;
}
status
}
async fn prepare_process_handles(
@@ -437,22 +499,32 @@ impl UnifiedExecProcessManager {
started_at: Instant,
process_id: String,
tty: bool,
network_attempt_id: Option<String>,
transcript: Arc<tokio::sync::Mutex<HeadTailBuffer>>,
) {
let network_attempt_id_for_watcher = network_attempt_id.clone();
let entry = ProcessEntry {
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id: process_id.clone(),
command: command.to_vec(),
tty,
network_attempt_id,
session: Arc::downgrade(&context.session),
last_used: started_at,
};
let number_processes = {
let (number_processes, pruned_entry) = {
let mut store = self.process_store.lock().await;
Self::prune_processes_if_needed(&mut store);
let pruned_entry = Self::prune_processes_if_needed(&mut store);
store.processes.insert(process_id.clone(), entry);
store.processes.len()
(store.processes.len(), pruned_entry)
};
// prune_processes_if_needed runs while holding process_store; do async
// network-approval cleanup only after dropping that lock.
if let Some(pruned_entry) = pruned_entry {
Self::unregister_network_attempt_for_entry(&pruned_entry).await;
pruned_entry.process.terminate();
}
if number_processes >= WARNING_UNIFIED_EXEC_PROCESSES {
context
@@ -471,10 +543,21 @@ impl UnifiedExecProcessManager {
context.call_id.clone(),
command.to_vec(),
cwd,
process_id,
process_id.clone(),
transcript,
started_at,
);
if context.turn.config.managed_network_requirements_enabled()
&& let Some(network_attempt_id) = network_attempt_id_for_watcher
{
spawn_network_denial_watcher(
Arc::clone(&process),
Arc::clone(&context.session),
process_id,
network_attempt_id,
);
}
}
pub(crate) async fn open_session_with_exec_env(
@@ -516,7 +599,7 @@ impl UnifiedExecProcessManager {
request: &ExecCommandRequest,
cwd: PathBuf,
context: &UnifiedExecContext,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
@@ -550,6 +633,7 @@ impl UnifiedExecProcessManager {
turn: context.turn.as_ref(),
call_id: context.call_id.clone(),
tool_name: "exec_command".to_string(),
network_attempt_id: None,
};
orchestrator
.run(
@@ -560,6 +644,7 @@ impl UnifiedExecProcessManager {
context.turn.approval_policy,
)
.await
.map(|result| (result.output, result.deferred_network_approval))
.map_err(|e| UnifiedExecError::create_process(format!("{e:?}")))
}
@@ -643,9 +728,9 @@ impl UnifiedExecProcessManager {
collected
}
fn prune_processes_if_needed(store: &mut ProcessStore) -> bool {
fn prune_processes_if_needed(store: &mut ProcessStore) -> Option<ProcessEntry> {
if store.processes.len() < MAX_UNIFIED_EXEC_PROCESSES {
return false;
return None;
}
let meta: Vec<(String, Instant, bool)> = store
@@ -655,13 +740,10 @@ impl UnifiedExecProcessManager {
.collect();
if let Some(process_id) = Self::process_id_to_prune_from_meta(&meta) {
if let Some(entry) = store.remove(&process_id) {
entry.process.terminate();
}
return true;
return store.remove(&process_id);
}
false
None
}
// Centralized pruning policy so we can easily swap strategies later.
@@ -706,6 +788,7 @@ impl UnifiedExecProcessManager {
};
for entry in entries {
Self::unregister_network_attempt_for_entry(&entry).await;
entry.process.terminate();
}
}
+1
View File
@@ -37,6 +37,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
expiration: 1000.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -93,6 +93,7 @@ impl EscalateServer {
expiration: ExecExpiration::Cancellation(cancel_rx),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -76,6 +76,7 @@ async fn run_cmd_result_with_writable_roots(
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -123,7 +124,7 @@ async fn should_skip_bwrap_tests() -> bool {
.await
{
Ok(output) => is_bwrap_unavailable_output(&output),
Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => {
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => {
is_bwrap_unavailable_output(&output)
}
// Probe timeouts are not actionable for the bwrap-specific assertions below;
@@ -142,7 +143,7 @@ fn expect_denied(
assert_ne!(output.exit_code, 0, "{context}: expected nonzero exit code");
output
}
Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output,
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output,
Err(err) => panic!("{context}: {err:?}"),
}
}
@@ -236,6 +237,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
expiration: NETWORK_TIMEOUT_MS.into(),
env: create_env_from_core_vars(),
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -257,7 +259,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
let output = match result {
Ok(output) => output,
Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output,
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output,
_ => {
panic!("expected sandbox denied error, got: {result:?}");
}
@@ -221,6 +221,7 @@ async fn run_codex_tool_session_inner(
reason: _,
proposed_execpolicy_amendment: _,
parsed_cmd,
network_approval_context: _,
}) => {
handle_exec_approval_request(
command,
+4
View File
@@ -18,7 +18,9 @@ pub use config::NetworkMode;
pub use config::NetworkProxyConfig;
pub use config::host_and_port_from_network_addr;
pub use network_policy::NetworkDecision;
pub use network_policy::NetworkDecisionSource;
pub use network_policy::NetworkPolicyDecider;
pub use network_policy::NetworkPolicyDecision;
pub use network_policy::NetworkPolicyRequest;
pub use network_policy::NetworkPolicyRequestArgs;
pub use network_policy::NetworkProtocol;
@@ -34,6 +36,8 @@ pub use proxy::PROXY_URL_ENV_KEYS;
pub use proxy::has_proxy_url_env_vars;
pub use proxy::proxy_url_env_value;
pub use runtime::BlockedRequest;
pub use runtime::BlockedRequestArgs;
pub use runtime::BlockedRequestObserver;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyState;
+4 -2
View File
@@ -26,7 +26,8 @@ impl NetworkProtocol {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkPolicyDecision {
Deny,
Ask,
@@ -41,7 +42,8 @@ impl NetworkPolicyDecision {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NetworkDecisionSource {
BaselinePolicy,
ModeGuard,
+22 -8
View File
@@ -3,7 +3,7 @@ use crate::config;
use crate::http_proxy;
use crate::metadata::proxy_username_for_attempt_id;
use crate::network_policy::NetworkPolicyDecider;
use crate::runtime::BlockedRequest;
use crate::runtime::BlockedRequestObserver;
use crate::runtime::unix_socket_permissions_supported;
use crate::socks5;
use crate::state::NetworkProxyState;
@@ -72,6 +72,7 @@ pub struct NetworkProxyBuilder {
admin_addr: Option<SocketAddr>,
managed_by_codex: bool,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
}
impl Default for NetworkProxyBuilder {
@@ -83,6 +84,7 @@ impl Default for NetworkProxyBuilder {
admin_addr: None,
managed_by_codex: true,
policy_decider: None,
blocked_request_observer: None,
}
}
}
@@ -126,12 +128,31 @@ impl NetworkProxyBuilder {
self
}
pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
where
O: BlockedRequestObserver,
{
self.blocked_request_observer = Some(Arc::new(observer));
self
}
pub fn blocked_request_observer_arc(
mut self,
observer: Arc<dyn BlockedRequestObserver>,
) -> Self {
self.blocked_request_observer = Some(observer);
self
}
pub async fn build(self) -> Result<NetworkProxy> {
let state = self.state.ok_or_else(|| {
anyhow::anyhow!(
"NetworkProxyBuilder requires a state; supply one via builder.state(...)"
)
})?;
state
.set_blocked_request_observer(self.blocked_request_observer.clone())
.await;
let current_cfg = state.current_cfg().await?;
let (requested_http_addr, requested_socks_addr, requested_admin_addr, reserved_listeners) =
if self.managed_by_codex {
@@ -399,13 +420,6 @@ impl NetworkProxy {
self.admin_addr
}
pub async fn latest_blocked_request_for_attempt(
&self,
attempt_id: &str,
) -> Result<Option<BlockedRequest>> {
self.state.latest_blocked_for_attempt(attempt_id).await
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
self.apply_to_env_for_attempt(env, None);
}
+50 -72
View File
@@ -20,6 +20,7 @@ use globset::GlobSet;
use serde::Serialize;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::future::Future;
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
@@ -162,9 +163,33 @@ pub trait ConfigReloader: Send + Sync {
async fn reload_now(&self) -> Result<ConfigState>;
}
#[async_trait]
pub trait BlockedRequestObserver: Send + Sync + 'static {
async fn on_blocked_request(&self, request: BlockedRequest);
}
#[async_trait]
impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
async fn on_blocked_request(&self, request: BlockedRequest) {
(**self).on_blocked_request(request).await
}
}
#[async_trait]
impl<F, Fut> BlockedRequestObserver for F
where
F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send,
{
async fn on_blocked_request(&self, request: BlockedRequest) {
(self)(request).await
}
}
pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
}
impl std::fmt::Debug for NetworkProxyState {
@@ -180,18 +205,36 @@ impl Clone for NetworkProxyState {
Self {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.clone(),
}
}
}
impl NetworkProxyState {
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_blocked_observer(state, reloader, None)
}
pub fn with_reloader_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
}
}
pub async fn set_blocked_request_observer(
&self,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) {
let mut observer = self.blocked_request_observer.write().await;
*observer = blocked_request_observer;
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
// `config.toml` (including Codex-managed writes) take effect without a restart.
@@ -312,6 +355,8 @@ impl NetworkProxyState {
pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
self.reload_if_needed().await?;
let blocked_for_observer = entry.clone();
let blocked_request_observer = self.blocked_request_observer.read().await.clone();
let violation_line = blocked_request_violation_log_line(&entry);
let mut guard = self.state.write().await;
let host = entry.host.clone();
@@ -340,6 +385,11 @@ impl NetworkProxyState {
guard.blocked.len()
);
debug!("{violation_line}");
drop(guard);
if let Some(observer) = blocked_request_observer {
observer.on_blocked_request(blocked_for_observer).await;
}
Ok(())
}
@@ -351,20 +401,6 @@ impl NetworkProxyState {
Ok(guard.blocked.iter().cloned().collect())
}
pub async fn latest_blocked_for_attempt(
&self,
attempt_id: &str,
) -> Result<Option<BlockedRequest>> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard
.blocked
.iter()
.rev()
.find(|entry| entry.attempt_id.as_deref() == Some(attempt_id))
.cloned())
}
/// Drain and return the buffered blocked-request entries in FIFO order.
pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
self.reload_if_needed().await?;
@@ -692,64 +728,6 @@ mod tests {
assert_eq!(drained[0].port, snapshot[0].port);
}
#[tokio::test]
async fn latest_blocked_for_attempt_returns_latest_matching_entry() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: "one.example.com".to_string(),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
}))
.await
.expect("entry should be recorded");
state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: "two.example.com".to_string(),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-2".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
}))
.await
.expect("entry should be recorded");
state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: "three.example.com".to_string(),
reason: "not_allowed".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
}))
.await
.expect("entry should be recorded");
let latest = state
.latest_blocked_for_attempt("attempt-1")
.await
.expect("lookup should succeed")
.expect("attempt should have a blocked entry");
assert_eq!(latest.host, "three.example.com");
}
#[tokio::test]
async fn drain_blocked_returns_buffered_window() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
+22
View File
@@ -37,6 +37,24 @@ impl From<Vec<String>> for ExecPolicyAmendment {
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum NetworkApprovalProtocol {
// TODO(viyatb): Add websocket protocol variants when managed proxy policy
// decisions expose websocket traffic as a distinct approval context.
Http,
#[serde(alias = "https_connect", alias = "http-connect")]
Https,
Socks5Tcp,
Socks5Udp,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct NetworkApprovalContext {
pub host: String,
pub protocol: NetworkApprovalProtocol,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecApprovalRequestEvent {
/// Identifier for the associated exec call, if available.
@@ -52,6 +70,10 @@ pub struct ExecApprovalRequestEvent {
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Optional network context for a blocked request that can be approved.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub network_approval_context: Option<NetworkApprovalContext>,
/// Proposed execpolicy amendment that can be applied to allow future runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
+2
View File
@@ -54,6 +54,8 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent;
pub use crate::approvals::ElicitationAction;
pub use crate::approvals::ExecApprovalRequestEvent;
pub use crate::approvals::ExecPolicyAmendment;
pub use crate::approvals::NetworkApprovalContext;
pub use crate::approvals::NetworkApprovalProtocol;
pub use crate::request_user_input::RequestUserInputEvent;
/// Open/close tags for special user-input blocks. Used across crates to avoid
+7
View File
@@ -2476,6 +2476,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
@@ -2520,6 +2521,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
@@ -2570,6 +2572,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
command: vec!["bash".into(), "-lc".into(), long],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
@@ -5450,6 +5453,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
@@ -5506,6 +5510,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
@@ -5553,6 +5558,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
command: command.clone(),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
parsed_cmd: vec![],
};
@@ -5912,6 +5918,7 @@ async fn status_widget_and_approval_modal_snapshot() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello world".into(),