Forward executor network policy decisions for auditing (#38670)

## What changed

- Add a best-effort `network/policyDecision` notification for final domain and non-domain policy decisions made by executor-local proxies.
- Validate notifications against the active process on the controller and emit audit events with controller-trusted session and execution metadata.
- Reserve outbound RPC capacity so audit notifications cannot block control messages, and expose valid `chatgpt-account-id` header values for audit attribution.

## Testing

- Cover notification serialization, proxy decision capture, executor-to-controller delivery, trusted metadata handling, and reserved RPC capacity.

GitOrigin-RevId: a39f96a6b3d9401c03d54eaef5b9a6d3fe0da78b
This commit is contained in:
viyatb-oai
2026-08-14 23:36:23 +00:00
committed by copyberry
parent a186f5484d
commit 15fde8c1f2
15 changed files with 655 additions and 19 deletions

View File

@@ -48,6 +48,8 @@ pub use mitm_hook::MitmHookConfig;
pub use mitm_hook::MitmHookMatchConfig;
pub use network_policy::NetworkDecision;
pub use network_policy::NetworkDecisionSource;
pub use network_policy::NetworkPolicyAuditEvent;
pub use network_policy::NetworkPolicyAuditObserver;
pub use network_policy::NetworkPolicyDecider;
pub use network_policy::NetworkPolicyDeciderFuture;
pub use network_policy::NetworkPolicyDecision;

View File

@@ -27,6 +27,27 @@ pub enum NetworkProtocol {
Socks5Udp,
}
/// A completed network-policy audit decision without tenant or session identity.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetworkPolicyAuditEvent {
pub timestamp: String,
pub scope: String,
pub decision: String,
pub source: String,
pub reason: String,
pub protocol: NetworkProtocol,
pub host: String,
pub port: u16,
pub method: Option<String>,
pub client: Option<String>,
pub policy_override: bool,
}
/// Observes final network-policy decisions without delaying or altering enforcement.
///
/// Implementations must return immediately and treat notification delivery as best effort.
pub type NetworkPolicyAuditObserver = Arc<dyn Fn(NetworkPolicyAuditEvent) + Send + Sync + 'static>;
impl NetworkProtocol {
pub const fn as_policy_protocol(self) -> &'static str {
match self {
@@ -236,11 +257,12 @@ struct PolicyAuditEventArgs<'a> {
fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
let metadata = state.audit_metadata();
let timestamp = audit_timestamp();
tracing::event!(
target: AUDIT_TARGET,
tracing::Level::INFO,
event.name = POLICY_DECISION_EVENT_NAME,
event.timestamp = %audit_timestamp(),
event.timestamp = %timestamp,
conversation.id = metadata.conversation_id.as_deref(),
app.version = metadata.app_version.as_deref(),
auth_mode = metadata.auth_mode.as_deref(),
@@ -262,6 +284,21 @@ fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs
execution.id = args.execution_id,
network.policy.override = args.policy_override,
);
if let Some(observer) = &state.policy_audit_observer {
observer(NetworkPolicyAuditEvent {
timestamp,
scope: args.scope.to_string(),
decision: args.decision.to_string(),
source: args.source.to_string(),
reason: args.reason.to_string(),
protocol: args.protocol,
host: args.server_address.to_string(),
port: args.server_port,
method: args.method.map(str::to_string),
client: args.client_addr.map(str::to_string),
policy_override: args.policy_override,
});
}
}
fn audit_timestamp() -> String {
@@ -633,6 +670,53 @@ mod tests {
})
}
#[tokio::test(flavor = "current_thread")]
async fn policy_audit_observer_receives_domain_and_non_domain_decisions() {
let mut state = network_proxy_state_for_policy(NetworkProxyConfig::default());
let (captured_tx, captured_rx) = std::sync::mpsc::channel();
state.set_policy_audit_observer(Arc::new(move |event| {
captured_tx
.send(event)
.expect("observer should capture the policy decision");
}));
let decider: Arc<dyn NetworkPolicyDecider> =
Arc::new(|_request| async { NetworkDecision::Allow });
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
environment_id: None,
client_addr: None,
method: None,
command: None,
exec_policy_hint: None,
});
evaluate_host_policy(&state, Some(&decider), &request)
.await
.expect("evaluate domain policy");
emit_block_decision_audit_event(
&state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some("POST"),
client_addr: None,
},
);
let events: Vec<_> = captured_rx.try_iter().collect();
assert_eq!(
events
.iter()
.map(|event| (event.scope.as_str(), event.decision.as_str()))
.collect::<Vec<_>>(),
vec![("domain", "allow"), ("non_domain", "deny")]
);
}
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_emits_domain_event_for_decider_allow_override() {
let state = network_proxy_state_for_policy(NetworkProxyConfig::default());

View File

@@ -7,6 +7,7 @@ use crate::mitm::MitmState;
use crate::mitm_hook::HookEvaluation;
use crate::mitm_hook::MitmHooksByHost;
use crate::mitm_hook::evaluate_mitm_hooks;
use crate::network_policy::NetworkPolicyAuditObserver;
use crate::policy::Host;
use crate::policy::is_loopback_host;
use crate::policy::is_non_public_ip;
@@ -232,6 +233,7 @@ pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
pub(crate) policy_audit_observer: Option<NetworkPolicyAuditObserver>,
credential_broker: CredentialBroker,
audit_metadata: NetworkProxyAuditMetadata,
execution_attributions: Arc<Mutex<HashMap<String, ExecutionAttribution>>>,
@@ -266,6 +268,7 @@ impl Clone for NetworkProxyState {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.clone(),
policy_audit_observer: self.policy_audit_observer.clone(),
credential_broker: self.credential_broker.clone(),
audit_metadata: self.audit_metadata.clone(),
execution_attributions: self.execution_attributions.clone(),
@@ -351,6 +354,7 @@ impl NetworkProxyState {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
policy_audit_observer: None,
audit_metadata,
execution_attributions: Arc::new(Mutex::new(HashMap::new())),
environment_id: None,
@@ -413,6 +417,11 @@ impl NetworkProxyState {
*observer = blocked_request_observer;
}
/// Installs a best-effort observer for every final domain and non-domain policy decision.
pub fn set_policy_audit_observer(&mut self, observer: NetworkPolicyAuditObserver) {
self.policy_audit_observer = Some(observer);
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}