1use std::{
4 collections::VecDeque,
5 net::{IpAddr, SocketAddr},
6 sync::{
7 Arc, Mutex,
8 atomic::{AtomicBool, Ordering},
9 },
10 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
11};
12
13use serde::Serialize;
14use serde_json::{Value, json};
15use soma_provider_core::{BrokerCapability, HostCapabilities, NetworkCapability};
16use url::Url;
17
18use crate::{
19 broker_state::BrokerStateStore,
20 python_protocol::{
21 PythonActorContext, PythonRunnerError, PythonRunnerErrorCode, PythonRunnerErrorPhase,
22 PythonRunnerHostCall,
23 },
24};
25
26const MAX_AUDIT_EVENTS: usize = 256;
27type HostResult<T> = Result<T, Box<PythonRunnerError>>;
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PythonExecutionProfile {
33 Disabled,
34 #[default]
35 Trusted,
36 Brokered,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct PythonHostAuditEvent {
42 pub unix_ms: u64,
43 pub invocation_id: String,
44 pub operation: &'static str,
45 pub allowed: bool,
46 pub detail: String,
47}
48
49pub struct PythonHostBroker {
51 profile: PythonExecutionProfile,
52 network: Option<NetworkCapability>,
53 broker: Option<BrokerCapability>,
54 max_http_response_bytes: usize,
55 state: Result<Arc<BrokerStateStore>, String>,
56 audit: Mutex<VecDeque<PythonHostAuditEvent>>,
57 cancelled: Arc<AtomicBool>,
58}
59
60impl std::fmt::Debug for PythonHostBroker {
61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 formatter
63 .debug_struct("PythonHostBroker")
64 .field("profile", &self.profile)
65 .field("network", &self.network)
66 .field("broker", &self.broker)
67 .finish_non_exhaustive()
68 }
69}
70
71impl PythonHostBroker {
72 #[must_use]
73 pub fn new(
74 profile: PythonExecutionProfile,
75 capabilities: &HostCapabilities,
76 cancelled: Arc<AtomicBool>,
77 ) -> Arc<Self> {
78 Arc::new(Self {
79 profile,
80 network: capabilities.network.clone(),
81 broker: capabilities.broker.clone(),
82 max_http_response_bytes: 256 * 1024,
83 state: BrokerStateStore::configured(),
84 audit: Mutex::new(VecDeque::new()),
85 cancelled,
86 })
87 }
88
89 #[must_use]
90 pub fn profile(&self) -> PythonExecutionProfile {
91 self.profile
92 }
93
94 #[must_use]
95 pub fn audit_events(&self) -> Vec<PythonHostAuditEvent> {
96 self.audit
97 .lock()
98 .expect("Python host audit lock should not be poisoned")
99 .iter()
100 .cloned()
101 .collect()
102 }
103
104 pub(crate) fn begin_invocation(&self) {
105 self.cancelled.store(false, Ordering::Release);
106 }
107
108 pub(crate) fn cancel_invocation(&self) {
109 self.cancelled.store(true, Ordering::Release);
110 }
111
112 pub async fn execute(
113 &self,
114 call: &PythonRunnerHostCall,
115 actor: Option<&PythonActorContext>,
116 ) -> HostResult<Value> {
117 if self.profile == PythonExecutionProfile::Disabled {
118 return Err(self.denied(call, "Python execution is disabled"));
119 }
120 let result = match call {
121 PythonRunnerHostCall::Http {
122 invocation_id,
123 request,
124 ..
125 } => {
126 self.require_scope(actor, false, call)?;
127 self.http(invocation_id, request).await
128 }
129 PythonRunnerHostCall::Secret {
130 invocation_id,
131 name,
132 ..
133 } => {
134 self.require_scope(actor, false, call)?;
135 self.secret(invocation_id, name)
136 }
137 PythonRunnerHostCall::StateGet {
138 invocation_id, key, ..
139 } => {
140 self.require_scope(actor, false, call)?;
141 self.state_get(invocation_id, key).await
142 }
143 PythonRunnerHostCall::StatePut {
144 invocation_id,
145 key,
146 value,
147 ..
148 } => {
149 self.require_scope(actor, true, call)?;
150 self.state_put(invocation_id, key, value).await
151 }
152 PythonRunnerHostCall::Log {
153 invocation_id,
154 level,
155 message,
156 fields,
157 ..
158 } => {
159 self.require_scope(actor, false, call)?;
160 self.log(invocation_id, level, message, fields)
161 }
162 PythonRunnerHostCall::Metric {
163 invocation_id,
164 name,
165 value,
166 attributes,
167 ..
168 } => {
169 self.require_scope(actor, false, call)?;
170 self.metric(invocation_id, name, value, attributes)
171 }
172 PythonRunnerHostCall::Progress {
173 invocation_id,
174 current,
175 total,
176 message,
177 ..
178 } => {
179 self.require_scope(actor, false, call)?;
180 self.progress(invocation_id, *current, *total, message.as_deref())
181 }
182 PythonRunnerHostCall::Cancelled { invocation_id, .. } => {
183 let value = self.cancelled.load(Ordering::Acquire);
184 self.record(invocation_id, "cancelled", true, "queried".to_owned());
185 Ok(Value::Bool(value))
186 }
187 };
188 if let Err(error) = &result {
189 self.record(
190 invocation_id(call),
191 operation(call),
192 false,
193 error.public_message.clone(),
194 );
195 }
196 result
197 }
198
199 fn require_scope(
200 &self,
201 actor: Option<&PythonActorContext>,
202 write: bool,
203 call: &PythonRunnerHostCall,
204 ) -> HostResult<()> {
205 let actor =
206 actor.ok_or_else(|| self.denied(call, "authenticated actor context is required"))?;
207 let allowed = actor
208 .scopes
209 .iter()
210 .any(|scope| scope == "soma:write" || (!write && scope == "soma:read"));
211 if allowed {
212 Ok(())
213 } else {
214 Err(self.denied(call, "actor scopes do not authorize this host operation"))
215 }
216 }
217
218 async fn http(&self, invocation_id: &str, request: &Value) -> HostResult<Value> {
219 let capability = self
220 .network
221 .as_ref()
222 .filter(|capability| capability.enabled)
223 .ok_or_else(|| self.policy_error("provider did not declare network capability"))?;
224 let method = request
225 .get("method")
226 .and_then(Value::as_str)
227 .unwrap_or("GET");
228 let raw_url = request
229 .get("url")
230 .and_then(Value::as_str)
231 .ok_or_else(|| self.policy_error("HTTP request URL is required"))?;
232 let url =
233 Url::parse(raw_url).map_err(|_| self.policy_error("HTTP request URL is invalid"))?;
234 if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
235 return Err(self.policy_error("brokered HTTP requires HTTPS without URL credentials"));
236 }
237 let host = url
238 .host_str()
239 .ok_or_else(|| self.policy_error("HTTP request host is required"))?
240 .to_owned();
241 if !capability
242 .allowed_hosts
243 .iter()
244 .any(|allowed| allowed == &host)
245 {
246 return Err(self.policy_error("HTTP request host is not declared"));
247 }
248 let port = url.port_or_known_default().unwrap_or(443);
249 let addresses = tokio::net::lookup_host((host.as_str(), port))
250 .await
251 .map_err(|_| self.policy_error("HTTP host resolution failed"))?
252 .collect::<Vec<_>>();
253 if addresses.is_empty() || addresses.iter().any(|address| !public_ip(address.ip())) {
254 return Err(self.policy_error("HTTP host resolved to a non-public address"));
255 }
256
257 let mut builder = reqwest::Client::builder()
258 .no_proxy()
259 .redirect(reqwest::redirect::Policy::none())
260 .timeout(Duration::from_secs(10))
261 .https_only(true);
262 for address in addresses {
263 builder = builder.resolve(&host, address);
264 }
265 let client = builder
266 .build()
267 .map_err(|_| self.policy_error("HTTP client initialization failed"))?;
268 let method = reqwest::Method::from_bytes(method.as_bytes())
269 .map_err(|_| self.policy_error("HTTP method is invalid"))?;
270 let mut outbound = client.request(method, url);
271 if let Some(headers) = request.get("headers").and_then(Value::as_object) {
272 for (name, value) in headers {
273 if forbidden_forwarded_header(name) {
274 return Err(self.policy_error("HTTP header is controlled by the broker"));
275 }
276 let value = value
277 .as_str()
278 .ok_or_else(|| self.policy_error("HTTP header values must be strings"))?;
279 outbound = outbound.header(name, value);
280 }
281 }
282 if let Some(body) = request.get("body_base64").and_then(Value::as_str) {
283 use base64::Engine as _;
284
285 let body = base64::engine::general_purpose::STANDARD
286 .decode(body)
287 .map_err(|_| self.policy_error("HTTP request body_base64 is invalid"))?;
288 outbound = outbound.body(body);
289 } else if let Some(body) = request.get("body").and_then(Value::as_str) {
290 outbound = outbound.body(body.to_owned());
292 }
293 let mut response = outbound
294 .send()
295 .await
296 .map_err(|_| self.policy_error("HTTP request failed"))?;
297 if response.status().is_redirection() {
298 return Err(
299 self.policy_error("HTTP redirects are not followed by the capability broker")
300 );
301 }
302 let status = response.status().as_u16();
303 if response
304 .content_length()
305 .is_some_and(|length| length > self.max_http_response_bytes as u64)
306 {
307 return Err(self.policy_error("HTTP response exceeds broker limit"));
308 }
309 let mut bytes = Vec::new();
310 while let Some(chunk) = response
311 .chunk()
312 .await
313 .map_err(|_| self.policy_error("HTTP response body failed"))?
314 {
315 if bytes.len().saturating_add(chunk.len()) > self.max_http_response_bytes {
316 return Err(self.policy_error("HTTP response exceeds broker limit"));
317 }
318 bytes.extend_from_slice(&chunk);
319 }
320 self.record(
321 invocation_id,
322 "http",
323 true,
324 format!(
325 "https://{host}:{port} status={status} bytes={}",
326 bytes.len()
327 ),
328 );
329 use base64::Engine as _;
330
331 let mut result = json!({
332 "status": status,
333 "body_base64": base64::engine::general_purpose::STANDARD.encode(&bytes),
334 });
335 if let Ok(body) = String::from_utf8(bytes)
336 && let Some(object) = result.as_object_mut()
337 {
338 object.insert("body".to_owned(), Value::String(body));
339 }
340 Ok(result)
341 }
342
343 fn secret(&self, invocation_id: &str, name: &str) -> HostResult<Value> {
344 let capability = self.broker_capability()?;
345 if !capability
346 .secret_names
347 .iter()
348 .any(|allowed| allowed == name)
349 {
350 return Err(self.policy_error("secret name is not declared"));
351 }
352 let variable = crate::secret_name::environment_name(name)
353 .map_err(|message| self.policy_error(&message))?;
354 let secret = std::env::var(variable)
355 .map_err(|_| self.policy_error("declared secret is unavailable"))?;
356 self.record(invocation_id, "secret", true, name.to_owned());
357 Ok(Value::String(secret))
358 }
359
360 async fn state_get(&self, invocation_id: &str, key: &str) -> HostResult<Value> {
361 let namespace = self.state_namespace()?.to_owned();
362 let key = key.to_owned();
363 let task_key = key.clone();
364 let state = self.state_store()?;
365 let cancelled = self.cancelled.clone();
366 let deadline = Instant::now() + Duration::from_secs(10);
367 let result = tokio::task::spawn_blocking(move || {
368 state.get(&namespace, &task_key, deadline, Some(&cancelled))
369 })
370 .await
371 .map_err(|_| self.policy_error("provider state task failed"))?
372 .map_err(|message| self.policy_error(&message))?;
373 self.record(invocation_id, "state.get", true, key);
374 Ok(result)
375 }
376
377 async fn state_put(&self, invocation_id: &str, key: &str, value: &Value) -> HostResult<Value> {
378 let capability = self.broker_capability()?;
379 if !capability.state_write {
380 return Err(self.policy_error("provider did not declare state write access"));
381 }
382 let namespace = self.state_namespace()?.to_owned();
383 let state = self.state_store()?;
384 let key_owned = key.to_owned();
385 let value = value.clone();
386 let cancelled = self.cancelled.clone();
387 let deadline = Instant::now() + Duration::from_secs(10);
388 tokio::task::spawn_blocking(move || {
389 state.put(&namespace, &key_owned, &value, deadline, Some(&cancelled))
390 })
391 .await
392 .map_err(|_| self.policy_error("provider state task failed"))?
393 .map_err(|message| self.policy_error(&message))?;
394 self.record(invocation_id, "state.put", true, key.to_owned());
395 Ok(Value::Null)
396 }
397
398 fn log(
399 &self,
400 invocation_id: &str,
401 level: &str,
402 message: &str,
403 fields: &Value,
404 ) -> HostResult<Value> {
405 if !self.broker_capability()?.logging {
406 return Err(self.policy_error("provider did not declare structured logging"));
407 }
408 let message = self.public_diagnostic(message);
409 tracing::info!(
410 provider_invocation = invocation_id,
411 provider_level = level,
412 message,
413 fields = %self.public_diagnostic(&fields.to_string()),
414 "Python provider structured log"
415 );
416 self.record(invocation_id, "log", true, level.to_owned());
417 Ok(Value::Null)
418 }
419
420 fn metric(
421 &self,
422 invocation_id: &str,
423 name: &str,
424 value: &serde_json::Number,
425 attributes: &Value,
426 ) -> HostResult<Value> {
427 if !self.broker_capability()?.metrics {
428 return Err(self.policy_error("provider did not declare metrics"));
429 }
430 tracing::info!(
431 provider_invocation = invocation_id,
432 metric = name,
433 value = %value,
434 attributes = %self.public_diagnostic(&attributes.to_string()),
435 "Python provider metric"
436 );
437 self.record(invocation_id, "metric", true, name.to_owned());
438 Ok(Value::Null)
439 }
440
441 fn progress(
442 &self,
443 invocation_id: &str,
444 current: u64,
445 total: Option<u64>,
446 message: Option<&str>,
447 ) -> HostResult<Value> {
448 if !self.broker_capability()?.progress {
449 return Err(self.policy_error("provider did not declare progress"));
450 }
451 tracing::info!(
452 provider_invocation = invocation_id,
453 current,
454 ?total,
455 message = %self.public_diagnostic(message.unwrap_or_default()),
456 "Python provider progress"
457 );
458 self.record(
459 invocation_id,
460 "progress",
461 true,
462 format!("{current}/{total:?}"),
463 );
464 Ok(Value::Null)
465 }
466
467 fn broker_capability(&self) -> HostResult<&BrokerCapability> {
468 self.broker
469 .as_ref()
470 .filter(|capability| capability.enabled)
471 .ok_or_else(|| self.policy_error("provider did not declare broker capabilities"))
472 }
473
474 fn state_namespace(&self) -> HostResult<&str> {
475 self.broker_capability()?
476 .state_namespace
477 .as_deref()
478 .ok_or_else(|| self.policy_error("provider did not declare a state namespace"))
479 }
480
481 fn state_store(&self) -> HostResult<Arc<BrokerStateStore>> {
482 self.state
483 .as_ref()
484 .map(Arc::clone)
485 .map_err(|message| self.policy_error(message))
486 }
487
488 fn denied(&self, call: &PythonRunnerHostCall, message: &str) -> Box<PythonRunnerError> {
489 self.record(
490 invocation_id(call),
491 operation(call),
492 false,
493 message.to_owned(),
494 );
495 self.policy_error(message)
496 }
497
498 fn policy_error(&self, message: &str) -> Box<PythonRunnerError> {
499 Box::new(PythonRunnerError {
500 code: PythonRunnerErrorCode::PythonPolicyDenied,
501 phase: PythonRunnerErrorPhase::Policy,
502 provider: None,
503 source: None,
504 generation_id: None,
505 action: None,
506 retryable: false,
507 public_message: message.to_owned(),
508 })
509 }
510
511 fn record(&self, invocation_id: &str, operation: &'static str, allowed: bool, detail: String) {
512 let mut audit = self
513 .audit
514 .lock()
515 .expect("Python host audit lock should not be poisoned");
516 if audit.len() == MAX_AUDIT_EVENTS {
517 audit.pop_front();
518 }
519 audit.push_back(PythonHostAuditEvent {
520 unix_ms: SystemTime::now()
521 .duration_since(UNIX_EPOCH)
522 .unwrap_or_default()
523 .as_millis()
524 .min(u128::from(u64::MAX)) as u64,
525 invocation_id: invocation_id.to_owned(),
526 operation,
527 allowed,
528 detail: self.public_diagnostic(&detail),
529 });
530 }
531
532 fn public_diagnostic(&self, message: &str) -> String {
533 let names = self
534 .broker
535 .as_ref()
536 .map(|broker| broker.secret_names.as_slice())
537 .unwrap_or_default();
538 crate::secret_name::redact(message, names)
539 }
540}
541
542fn invocation_id(call: &PythonRunnerHostCall) -> &str {
543 match call {
544 PythonRunnerHostCall::Http { invocation_id, .. }
545 | PythonRunnerHostCall::Secret { invocation_id, .. }
546 | PythonRunnerHostCall::StateGet { invocation_id, .. }
547 | PythonRunnerHostCall::StatePut { invocation_id, .. }
548 | PythonRunnerHostCall::Log { invocation_id, .. }
549 | PythonRunnerHostCall::Metric { invocation_id, .. }
550 | PythonRunnerHostCall::Progress { invocation_id, .. }
551 | PythonRunnerHostCall::Cancelled { invocation_id, .. } => invocation_id,
552 }
553}
554
555fn operation(call: &PythonRunnerHostCall) -> &'static str {
556 match call {
557 PythonRunnerHostCall::Http { .. } => "http",
558 PythonRunnerHostCall::Secret { .. } => "secret",
559 PythonRunnerHostCall::StateGet { .. } => "state.get",
560 PythonRunnerHostCall::StatePut { .. } => "state.put",
561 PythonRunnerHostCall::Log { .. } => "log",
562 PythonRunnerHostCall::Metric { .. } => "metric",
563 PythonRunnerHostCall::Progress { .. } => "progress",
564 PythonRunnerHostCall::Cancelled { .. } => "cancelled",
565 }
566}
567
568fn forbidden_forwarded_header(name: &str) -> bool {
569 matches!(
570 name.to_ascii_lowercase().as_str(),
571 "host"
572 | "connection"
573 | "content-length"
574 | "proxy-authenticate"
575 | "proxy-authorization"
576 | "te"
577 | "trailer"
578 | "transfer-encoding"
579 | "upgrade"
580 )
581}
582
583fn public_ip(ip: IpAddr) -> bool {
584 match ip {
585 IpAddr::V4(ip) => {
586 let [first, second, third, _] = ip.octets();
587 !(ip.is_private()
588 || ip.is_loopback()
589 || ip.is_link_local()
590 || ip.is_broadcast()
591 || ip.is_documentation()
592 || ip.is_unspecified()
593 || ip.is_multicast()
594 || first == 0
595 || (first == 100 && (64..=127).contains(&second))
596 || (first == 192 && second == 0 && third == 0)
597 || (first == 192 && second == 88 && third == 99)
598 || (first == 198 && (18..=19).contains(&second))
599 || first >= 240)
600 }
601 IpAddr::V6(ip) => {
602 let segments = ip.segments();
603 (0x2000..=0x3fff).contains(&segments[0])
604 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
605 && !ip.is_multicast()
606 }
607 }
608}
609
610#[allow(dead_code)]
611fn _socket_address_is_public(address: SocketAddr) -> bool {
612 public_ip(address.ip())
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::python_protocol::{PythonActorContext, PythonRunnerHostCall};
619
620 fn capabilities(namespace: &str) -> HostCapabilities {
621 HostCapabilities {
622 broker: Some(BrokerCapability {
623 enabled: true,
624 state_namespace: Some(namespace.to_owned()),
625 state_write: true,
626 logging: true,
627 metrics: true,
628 progress: true,
629 ..BrokerCapability::default()
630 }),
631 ..HostCapabilities::default()
632 }
633 }
634
635 fn actor(scopes: &[&str]) -> PythonActorContext {
636 PythonActorContext {
637 actor_id: "actor".to_owned(),
638 scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(),
639 }
640 }
641
642 #[tokio::test]
643 async fn state_is_namespaced_and_actor_write_scope_is_required() {
644 let mut broker = PythonHostBroker::new(
645 PythonExecutionProfile::Brokered,
646 &capabilities("provider-a"),
647 Arc::new(AtomicBool::new(false)),
648 );
649 Arc::get_mut(&mut broker).unwrap().state = Ok(BrokerStateStore::in_memory_for_test());
650 let denied = broker
651 .execute(
652 &PythonRunnerHostCall::StatePut {
653 request_id: 1,
654 invocation_id: "invocation".to_owned(),
655 key: "count".to_owned(),
656 value: json!(1),
657 },
658 Some(&actor(&["soma:read"])),
659 )
660 .await
661 .expect_err("read scope must not grant state writes");
662 assert_eq!(denied.code, PythonRunnerErrorCode::PythonPolicyDenied);
663
664 broker
665 .execute(
666 &PythonRunnerHostCall::StatePut {
667 request_id: 2,
668 invocation_id: "invocation".to_owned(),
669 key: "count".to_owned(),
670 value: json!(2),
671 },
672 Some(&actor(&["soma:write"])),
673 )
674 .await
675 .expect("write scope and provider declaration intersect");
676 let value = broker
677 .execute(
678 &PythonRunnerHostCall::StateGet {
679 request_id: 3,
680 invocation_id: "invocation".to_owned(),
681 key: "count".to_owned(),
682 },
683 Some(&actor(&["soma:read"])),
684 )
685 .await
686 .expect("read scope can access declared state");
687 assert_eq!(value, json!(2));
688 }
689
690 #[tokio::test]
691 async fn disabled_profile_and_undeclared_services_fail_closed() {
692 let disabled = PythonHostBroker::new(
693 PythonExecutionProfile::Disabled,
694 &HostCapabilities::default(),
695 Arc::new(AtomicBool::new(false)),
696 );
697 let error = disabled
698 .execute(
699 &PythonRunnerHostCall::Cancelled {
700 request_id: 1,
701 invocation_id: "invocation".to_owned(),
702 },
703 None,
704 )
705 .await
706 .expect_err("disabled profile rejects host calls");
707 assert_eq!(error.code, PythonRunnerErrorCode::PythonPolicyDenied);
708 }
709
710 #[tokio::test]
711 async fn missing_actor_context_fails_closed() {
712 let broker = PythonHostBroker::new(
713 PythonExecutionProfile::Brokered,
714 &capabilities("provider-a"),
715 Arc::new(AtomicBool::new(false)),
716 );
717 let error = broker
718 .execute(
719 &PythonRunnerHostCall::StateGet {
720 request_id: 1,
721 invocation_id: "invocation".to_owned(),
722 key: "count".to_owned(),
723 },
724 None,
725 )
726 .await
727 .expect_err("brokered host services require an authenticated actor");
728 assert_eq!(error.code, PythonRunnerErrorCode::PythonPolicyDenied);
729 }
730
731 #[test]
732 fn diagnostics_and_network_targets_are_conservative() {
733 let broker = PythonHostBroker::new(
734 PythonExecutionProfile::Trusted,
735 &HostCapabilities::default(),
736 Arc::new(AtomicBool::new(false)),
737 );
738 assert_eq!(
739 broker.public_diagnostic("Authorization: bearer value"),
740 "[redacted]"
741 );
742 assert!(!public_ip("127.0.0.1".parse().unwrap()));
743 assert!(!public_ip("10.0.0.1".parse().unwrap()));
744 assert!(!public_ip("100.64.0.1".parse().unwrap()));
745 assert!(!public_ip("224.0.0.1".parse().unwrap()));
746 assert!(public_ip("1.1.1.1".parse().unwrap()));
747 assert!(forbidden_forwarded_header("Host"));
748 assert!(forbidden_forwarded_header("transfer-encoding"));
749 assert!(!forbidden_forwarded_header("authorization"));
750 }
751}