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
parent 854e91e422
commit b527ee2890
47 changed files with 1874 additions and 176 deletions

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));

View File

@@ -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?;

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 {

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;

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)
);
}
}

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),
}
}
}

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)?;

View File

@@ -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()),

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,