mirror of
https://github.com/openai/codex.git
synced 2026-04-26 07:35:29 +00:00
feat(network-proxy): add structured policy decision to blocked errors (#10420)
## Summary
Add explicit, model-visible network policy decision metadata to blocked
proxy responses/errors.
Introduces a standardized prefix line: `CODEX_NETWORK_POLICY_DECISION
{json}`
and wires it through blocked paths for:
- HTTP requests
- HTTPS CONNECT
- SOCKS5 TCP/UDP denials
## Why
The model should see *why* a request was blocked
(reason/source/protocol/host/port) so it can choose the correct next
action.
## Notes
- This PR is intentionally independent of config-layering/network-rule
runtime integration.
- Focus is blocked decision surface only.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use crate::config::NetworkMode;
|
||||
use crate::network_policy::NetworkDecision;
|
||||
use crate::network_policy::NetworkDecisionSource;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
@@ -9,8 +11,12 @@ use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_PROXY_DISABLED;
|
||||
use crate::responses::PolicyDecisionDetails;
|
||||
use crate::responses::blocked_header_value;
|
||||
use crate::responses::blocked_message_with_policy;
|
||||
use crate::responses::blocked_text_response_with_policy;
|
||||
use crate::responses::json_response;
|
||||
use crate::responses::policy_decision_prefix;
|
||||
use crate::runtime::unix_socket_permissions_supported;
|
||||
use crate::state::BlockedRequest;
|
||||
use crate::state::BlockedRequestArgs;
|
||||
@@ -141,9 +147,10 @@ async fn http_connect_accept(
|
||||
return Err(proxy_disabled_response(
|
||||
&app_state,
|
||||
host,
|
||||
authority.port,
|
||||
client_addr(&req),
|
||||
Some("CONNECT".to_string()),
|
||||
"http-connect",
|
||||
NetworkProtocol::HttpsConnect,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -159,7 +166,19 @@ async fn http_connect_accept(
|
||||
});
|
||||
|
||||
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(NetworkDecision::Deny { reason }) => {
|
||||
Ok(NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
decision,
|
||||
}) => {
|
||||
let details = PolicyDecisionDetails {
|
||||
decision,
|
||||
reason: &reason,
|
||||
source,
|
||||
protocol: NetworkProtocol::HttpsConnect,
|
||||
host: &host,
|
||||
port: authority.port,
|
||||
};
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: host.clone(),
|
||||
@@ -172,7 +191,7 @@ async fn http_connect_accept(
|
||||
.await;
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("CONNECT blocked (client={client}, host={host}, reason={reason})");
|
||||
return Err(blocked_text(&reason));
|
||||
return Err(blocked_text_with_details(&reason, &details));
|
||||
}
|
||||
Ok(NetworkDecision::Allow) => {
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
@@ -190,6 +209,14 @@ async fn http_connect_accept(
|
||||
.map_err(|err| internal_error("failed to read network mode", err))?;
|
||||
|
||||
if mode == NetworkMode::Limited {
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
protocol: NetworkProtocol::HttpsConnect,
|
||||
host: &host,
|
||||
port: authority.port,
|
||||
};
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: host.clone(),
|
||||
@@ -202,7 +229,10 @@ async fn http_connect_accept(
|
||||
.await;
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)");
|
||||
return Err(blocked_text(REASON_METHOD_NOT_ALLOWED));
|
||||
return Err(blocked_text_with_details(
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
&details,
|
||||
));
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(ProxyTarget(authority));
|
||||
@@ -346,9 +376,10 @@ async fn http_plain_proxy(
|
||||
return Ok(proxy_disabled_response(
|
||||
&app_state,
|
||||
socket_path,
|
||||
0,
|
||||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
"unix-socket",
|
||||
NetworkProtocol::Http,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -358,7 +389,7 @@ async fn http_plain_proxy(
|
||||
warn!(
|
||||
"unix socket blocked by method policy (client={client}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
|
||||
);
|
||||
return Ok(json_blocked("unix-socket", REASON_METHOD_NOT_ALLOWED));
|
||||
return Ok(json_blocked("unix-socket", REASON_METHOD_NOT_ALLOWED, None));
|
||||
}
|
||||
|
||||
if !unix_socket_permissions_supported() {
|
||||
@@ -387,7 +418,7 @@ async fn http_plain_proxy(
|
||||
Ok(false) => {
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("unix socket blocked (client={client}, path={socket_path})");
|
||||
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED))
|
||||
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED, None))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("unix socket check failed: {err}");
|
||||
@@ -420,9 +451,10 @@ async fn http_plain_proxy(
|
||||
return Ok(proxy_disabled_response(
|
||||
&app_state,
|
||||
host,
|
||||
port,
|
||||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
"http",
|
||||
NetworkProtocol::Http,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -438,7 +470,19 @@ async fn http_plain_proxy(
|
||||
});
|
||||
|
||||
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(NetworkDecision::Deny { reason }) => {
|
||||
Ok(NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
decision,
|
||||
}) => {
|
||||
let details = PolicyDecisionDetails {
|
||||
decision,
|
||||
reason: &reason,
|
||||
source,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: &host,
|
||||
port,
|
||||
};
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: host.clone(),
|
||||
@@ -451,7 +495,7 @@ async fn http_plain_proxy(
|
||||
.await;
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("request blocked (client={client}, host={host}, reason={reason})");
|
||||
return Ok(json_blocked(&host, &reason));
|
||||
return Ok(json_blocked(&host, &reason, Some(&details)));
|
||||
}
|
||||
Ok(NetworkDecision::Allow) => {}
|
||||
Err(err) => {
|
||||
@@ -461,6 +505,14 @@ async fn http_plain_proxy(
|
||||
}
|
||||
|
||||
if !method_allowed {
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: &host,
|
||||
port,
|
||||
};
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: host.clone(),
|
||||
@@ -476,7 +528,11 @@ async fn http_plain_proxy(
|
||||
warn!(
|
||||
"request blocked by method policy (client={client}, host={host}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
|
||||
);
|
||||
return Ok(json_blocked(&host, REASON_METHOD_NOT_ALLOWED));
|
||||
return Ok(json_blocked(
|
||||
&host,
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
Some(&details),
|
||||
));
|
||||
}
|
||||
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
@@ -540,11 +596,21 @@ fn client_addr<T: ExtensionsRef>(input: &T) -> Option<String> {
|
||||
.map(|info| info.peer_addr().to_string())
|
||||
}
|
||||
|
||||
fn json_blocked(host: &str, reason: &str) -> Response {
|
||||
fn json_blocked(host: &str, reason: &str, details: Option<&PolicyDecisionDetails<'_>>) -> Response {
|
||||
let (policy_decision_prefix, message) = details
|
||||
.map(|details| {
|
||||
(
|
||||
Some(policy_decision_prefix(details)),
|
||||
Some(blocked_message_with_policy(reason, details)),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
let response = BlockedResponse {
|
||||
status: "blocked",
|
||||
host,
|
||||
reason,
|
||||
policy_decision_prefix,
|
||||
message,
|
||||
};
|
||||
let mut resp = json_response(&response);
|
||||
*resp.status_mut() = StatusCode::FORBIDDEN;
|
||||
@@ -555,28 +621,42 @@ fn json_blocked(host: &str, reason: &str) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
fn blocked_text(reason: &str) -> Response {
|
||||
crate::responses::blocked_text_response(reason)
|
||||
fn blocked_text_with_details(reason: &str, details: &PolicyDecisionDetails<'_>) -> Response {
|
||||
blocked_text_response_with_policy(reason, details)
|
||||
}
|
||||
|
||||
async fn proxy_disabled_response(
|
||||
app_state: &NetworkProxyState,
|
||||
host: String,
|
||||
port: u16,
|
||||
client: Option<String>,
|
||||
method: Option<String>,
|
||||
protocol: &str,
|
||||
protocol: NetworkProtocol,
|
||||
) -> Response {
|
||||
let blocked_host = host.clone();
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host,
|
||||
host: blocked_host,
|
||||
reason: REASON_PROXY_DISABLED.to_string(),
|
||||
client,
|
||||
method,
|
||||
mode: None,
|
||||
protocol: protocol.to_string(),
|
||||
protocol: protocol.as_policy_protocol().to_string(),
|
||||
}))
|
||||
.await;
|
||||
text_response(StatusCode::SERVICE_UNAVAILABLE, "proxy disabled")
|
||||
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
protocol,
|
||||
host: &host,
|
||||
port,
|
||||
};
|
||||
text_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&blocked_message_with_policy(REASON_PROXY_DISABLED, &details),
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_error(context: &str, err: impl std::fmt::Display) -> Response {
|
||||
@@ -597,6 +677,10 @@ struct BlockedResponse<'a> {
|
||||
status: &'static str,
|
||||
host: &'a str,
|
||||
reason: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
policy_decision_prefix: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user