Report network disconnects during approval (#39284)

## Why

When a local proxy request disconnects before network approval completes, the
owning tool call needs a model-visible explanation instead of remaining tied to
the abandoned request.

## What changed

- Track disconnect timing while plain HTTP and CONNECT requests await policy
  decisions.
- Cancel the owning execution and report how long the request waited when it
  disconnects before approval completes.
- Preserve an explicit approval outcome when disconnect cleanup runs afterward.

## Testing

Added unit coverage for disconnect tracking and outcome precedence, plus
end-to-end coverage for plain HTTP and CONNECT requests.

GitOrigin-RevId: b354b29bbe86f38e252fcaf529541f177480136b
This commit is contained in:
Dylan Hurd
2026-08-18 21:11:53 +00:00
committed by copyberry
parent 2fe4e06cc2
commit ba37d0c45b
9 changed files with 353 additions and 68 deletions

View File

@@ -19,6 +19,7 @@ use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_PROXY_DISABLED;
use crate::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
use crate::request_disconnect::NetworkRequestDisconnect;
use crate::responses::PolicyDecisionDetails;
use crate::responses::blocked_header_value;
use crate::responses::blocked_message_with_policy;
@@ -179,6 +180,7 @@ async fn http_connect_accept(
environment_id: Option<String>,
mut req: Request,
) -> Result<(Response, Request), Response> {
let started_at = Instant::now();
let app_state = req
.extensions()
.get::<Arc<NetworkProxyState>>()
@@ -218,7 +220,8 @@ async fn http_connect_accept(
.await);
}
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
let disconnect = NetworkRequestDisconnect::default();
let mut request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::HttpsConnect,
host: host.clone(),
port: authority.port,
@@ -229,7 +232,14 @@ async fn http_connect_accept(
exec_policy_hint: None,
});
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
request.disconnect = Some(disconnect.clone());
match disconnect
.track_http_request(
started_at,
evaluate_host_policy(&app_state, policy_decider.as_ref(), &request),
)
.await
{
Ok(NetworkDecision::Deny {
reason,
source,
@@ -510,6 +520,7 @@ async fn http_plain_proxy(
environment_id: Option<String>,
mut req: Request,
) -> Result<Response, Infallible> {
let started_at = Instant::now();
let app_state = match req.extensions().get::<Arc<NetworkProxyState>>().cloned() {
Some(state) => state,
None => {
@@ -709,7 +720,8 @@ async fn http_plain_proxy(
.await);
}
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
let disconnect = NetworkRequestDisconnect::default();
let mut request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: host.clone(),
port,
@@ -720,7 +732,14 @@ async fn http_plain_proxy(
exec_policy_hint: None,
});
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
request.disconnect = Some(disconnect.clone());
match disconnect
.track_http_request(
started_at,
evaluate_host_policy(&app_state, policy_decider.as_ref(), &request),
)
.await
{
Ok(NetworkDecision::Deny {
reason,
source,

View File

@@ -16,6 +16,7 @@ mod policy;
mod proxy;
mod reasons;
mod remote_config;
mod request_disconnect;
mod responses;
mod runtime;
mod socks5;
@@ -82,6 +83,7 @@ pub use proxy::proxy_url_env_value;
pub use proxy::strip_managed_proxy_env;
pub use remote_config::RemoteNetworkProxyConfig;
pub use remote_config::RemoteNetworkProxyLaunchConfig;
pub use request_disconnect::NetworkRequestDisconnect;
pub use runtime::BlockedRequest;
pub use runtime::BlockedRequestArgs;
pub use runtime::BlockedRequestObserver;

View File

@@ -1,4 +1,5 @@
use crate::reasons::REASON_POLICY_DENIED;
use crate::request_disconnect::NetworkRequestDisconnect;
use crate::runtime::HostBlockDecision;
use crate::runtime::HostBlockReason;
use crate::state::NetworkProxyState;
@@ -106,6 +107,8 @@ pub struct NetworkPolicyRequest {
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
pub execution_id: Option<String>,
/// Present only when the local HTTP transport can identify an abandoned request.
pub disconnect: Option<NetworkRequestDisconnect>,
}
pub struct NetworkPolicyRequestArgs {
@@ -141,6 +144,7 @@ impl NetworkPolicyRequest {
command,
exec_policy_hint,
execution_id: None,
disconnect: None,
}
}
}

View File

@@ -0,0 +1,45 @@
use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
/// Transport metadata available while cleaning up an abandoned HTTP policy request.
/// This does not cancel the approval reviewer or change its decision.
#[derive(Clone, Debug, Default)]
pub struct NetworkRequestDisconnect(Arc<OnceLock<Duration>>);
impl NetworkRequestDisconnect {
pub fn elapsed(&self) -> Option<Duration> {
self.0.get().copied()
}
pub(crate) async fn track_http_request<F: Future>(
&self,
started_at: Instant,
future: F,
) -> F::Output {
// Publish the cause before dropping the policy future: its cleanup may
// immediately finish the owning tool call.
let mut future = pin!(future);
let mut guard = HttpRequestGuard(Some((self, started_at)));
let result = future.as_mut().await;
guard.0 = None;
result
}
}
struct HttpRequestGuard<'a>(Option<(&'a NetworkRequestDisconnect, Instant)>);
impl Drop for HttpRequestGuard<'_> {
fn drop(&mut self) {
if let Some((disconnect, started_at)) = self.0 {
let _ = disconnect.0.set(started_at.elapsed());
}
}
}
#[cfg(test)]
#[path = "request_disconnect_tests.rs"]
mod tests;

View File

@@ -0,0 +1,41 @@
use super::NetworkRequestDisconnect;
use pretty_assertions::assert_eq;
use std::future::pending;
use std::time::Duration;
use std::time::Instant;
#[tokio::test]
async fn disconnect_is_published_before_policy_cleanup() {
struct ObserveOnDrop(NetworkRequestDisconnect);
impl Drop for ObserveOnDrop {
fn drop(&mut self) {
assert!(self.0.elapsed().is_some());
}
}
let disconnect = NetworkRequestDisconnect::default();
let observer = ObserveOnDrop(disconnect.clone());
let started_at = Instant::now();
let decision = disconnect.track_http_request(started_at, async move {
let _observer = observer;
pending::<()>().await;
});
assert!(
tokio::time::timeout(Duration::from_millis(1), decision)
.await
.is_err()
);
assert!(disconnect.elapsed().expect("disconnect time") <= started_at.elapsed());
}
#[tokio::test]
async fn completed_policy_request_is_not_a_disconnect() {
let disconnect = NetworkRequestDisconnect::default();
assert_eq!(
disconnect
.track_http_request(Instant::now(), async { 42 })
.await,
42
);
assert_eq!(disconnect.elapsed(), None);
}