mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
codex-rs/core/src/tools/network_approval.rs
Normal file
675
codex-rs/core/src/tools/network_approval.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user