1use std::collections::HashMap;
59
60use ppe::praxis_policy_core::extensions::Extensions;
61use ppe::praxis_policy_core::extensions::container::{OwnedExtensions, chain_extends};
62use ppe::praxis_policy_core::extensions::delegation::DelegationExtension;
63use ppe::praxis_policy_core::extensions::guarded::Guarded;
64use ppe::praxis_policy_core::extensions::http::HttpExtension;
65use ppe::praxis_policy_core::extensions::security::SecurityExtension;
66use serde_json::Value;
67use tracing::warn;
68
69use crate::credential_payloads::{CREDENTIAL_HEADERS, is_public_header};
70use crate::error::HostError;
71
72pub const EXTENSIONS_FIELD: &str = "extensions";
76
77pub const MODIFIED_EXTENSIONS_FIELD: &str = "modified_extensions";
87
88fn is_sensitive(name: &str) -> bool {
91 CREDENTIAL_HEADERS
92 .iter()
93 .any(|s| name.eq_ignore_ascii_case(s))
94}
95
96fn public_http_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
98 headers
99 .iter()
100 .filter(|(name, value)| is_public_header(name, value, None, None))
101 .map(|(name, value)| (name.clone(), value.clone()))
102 .collect()
103}
104
105fn strip_sensitive(headers: &HashMap<String, String>) -> HashMap<String, String> {
107 headers
108 .iter()
109 .filter(|(name, _)| !is_sensitive(name))
110 .map(|(name, value)| (name.clone(), value.clone()))
111 .collect()
112}
113
114fn sanitize_http(http: &HttpExtension) -> HttpExtension {
119 HttpExtension {
120 request_headers: public_http_headers(&http.request_headers),
121 secret_headers: http.secret_headers.clone(),
122 response_headers: public_http_headers(&http.response_headers),
123 status: http.status,
124 method: http.method.clone(),
125 path: http.path.clone(),
126 host: http.host.clone(),
127 scheme: http.scheme.clone(),
128 }
129}
130
131fn returned_http(returned: &HttpExtension, inbound: Option<&HttpExtension>) -> HttpExtension {
145 HttpExtension {
146 request_headers: strip_sensitive(&returned.request_headers),
147 secret_headers: inbound
148 .map(|h| h.secret_headers.clone())
149 .unwrap_or_default(),
150 response_headers: strip_sensitive(&returned.response_headers),
151 status: inbound.and_then(|h| h.status),
152 method: inbound.and_then(|h| h.method.clone()),
153 path: inbound.and_then(|h| h.path.clone()),
154 host: inbound.and_then(|h| h.host.clone()),
155 scheme: inbound.and_then(|h| h.scheme.clone()),
156 }
157}
158
159pub fn attach_extensions(task: &mut Value, extensions: &Extensions) -> Result<(), HostError> {
173 let wire = to_wire(extensions)?;
174
175 if wire.as_object().is_none_or(serde_json::Map::is_empty) {
179 return Ok(());
180 }
181
182 let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
183 message: "task must be a JSON object to carry extensions".into(),
184 })?;
185 object.insert(EXTENSIONS_FIELD.to_string(), wire);
186
187 Ok(())
188}
189
190fn to_wire(extensions: &Extensions) -> Result<Value, HostError> {
197 let mut map = serde_json::Map::new();
198
199 let mut put = |key: &str, value: Result<Value, serde_json::Error>| -> Result<(), HostError> {
200 let value = value.map_err(|e| HostError::Protocol {
201 message: format!("could not serialize the '{key}' extension for the worker: {e}"),
202 })?;
203 map.insert(key.to_string(), value);
204 Ok(())
205 };
206
207 if let Some(request) = &extensions.request {
208 put("request", serde_json::to_value(request))?;
209 }
210 if let Some(agent) = &extensions.agent {
211 put("agent", serde_json::to_value(agent))?;
212 }
213 if let Some(http) = &extensions.http {
214 put("http", serde_json::to_value(sanitize_http(http)))?;
215 }
216 if let Some(security) = &extensions.security {
217 put("security", serde_json::to_value(security))?;
218 }
219 if let Some(delegation) = &extensions.delegation {
220 put("delegation", serde_json::to_value(delegation))?;
221 }
222 if let Some(mcp) = &extensions.mcp {
223 put("mcp", serde_json::to_value(mcp))?;
224 }
225 if let Some(completion) = &extensions.completion {
226 put("completion", serde_json::to_value(completion))?;
227 }
228 if let Some(provenance) = &extensions.provenance {
229 put("provenance", serde_json::to_value(provenance))?;
230 }
231 if let Some(llm) = &extensions.llm {
232 put("llm", serde_json::to_value(llm))?;
233 }
234 if let Some(framework) = &extensions.framework {
235 put("framework", serde_json::to_value(framework))?;
236 }
237 if let Some(meta) = &extensions.meta {
238 put("meta", serde_json::to_value(meta))?;
239 }
240 if let Some(custom) = &extensions.custom {
241 put("custom", serde_json::to_value(custom))?;
242 }
243 Ok(Value::Object(map))
246}
247
248pub fn parse_returned_extensions(
267 response: &Value,
268 inbound: &Extensions,
269) -> Result<Option<OwnedExtensions>, HostError> {
270 let Some(field) = response.get(MODIFIED_EXTENSIONS_FIELD) else {
271 return Ok(None);
272 };
273 owned_from_returned_slot(field, inbound)
274}
275
276pub fn owned_from_returned_slot(
282 field: &Value,
283 inbound: &Extensions,
284) -> Result<Option<OwnedExtensions>, HostError> {
285 if field.is_null() {
288 return Ok(None);
289 }
290
291 let object = field.as_object().ok_or_else(|| HostError::Protocol {
292 message: format!(
293 "the worker returned a '{MODIFIED_EXTENSIONS_FIELD}' field that is not a JSON object"
294 ),
295 })?;
296
297 let mut owned = inbound.cow_copy();
300
301 let mut applied = false;
306
307 fn slot<T: serde::de::DeserializeOwned>(
308 object: &serde_json::Map<String, Value>,
309 key: &str,
310 ) -> Result<Option<T>, HostError> {
311 match object.get(key) {
312 None | Some(Value::Null) => Ok(None),
313 Some(value) => serde_json::from_value(value.clone())
314 .map(Some)
315 .map_err(|e| HostError::Protocol {
316 message: format!(
317 "could not deserialize the '{key}' extension returned by the worker: {e}"
318 ),
319 }),
320 }
321 }
322
323 if let Some(http) = slot::<HttpExtension>(object, "http")?
342 && inbound.http_write_token.is_some()
343 {
344 owned.http = Some(Guarded::new(returned_http(&http, inbound.http.as_deref())));
347 applied = true;
348 }
349
350 if let Some(security) = slot::<SecurityExtension>(object, "security")?
357 && inbound.labels_write_token.is_some()
358 {
359 let mut merged = inbound
360 .security
361 .as_deref()
362 .cloned()
363 .unwrap_or_else(SecurityExtension::default);
364 for label in security.labels.iter() {
367 merged.add_label(label.clone());
368 }
369 owned.security = Some(merged);
370 applied = true;
371 }
372
373 if let Some(delegation) = slot::<DelegationExtension>(object, "delegation")?
380 && inbound.delegation_write_token.is_some()
381 {
382 let inbound_chain = inbound
383 .delegation
384 .as_deref()
385 .map(|d| d.chain.as_slice())
386 .unwrap_or_default();
387 if chain_extends(inbound_chain, &delegation.chain) {
388 owned.delegation = Some(delegation);
389 applied = true;
390 } else {
391 warn!(
392 inbound_hops = inbound_chain.len(),
393 returned_hops = delegation.chain.len(),
394 "dropping a returned delegation chain that does not extend the \
395 one the plugin was given — an append-only chain cannot be \
396 shortened or rewritten"
397 );
398 }
399 }
400
401 if let Some(custom) = slot::<HashMap<String, Value>>(object, "custom")? {
406 owned.custom = Some(custom);
407 applied = true;
408 }
409
410 if !applied {
414 return Ok(None);
415 }
416
417 Ok(Some(owned))
418}
419
420#[cfg(test)]
424fn same_arc<T>(a: &Option<std::sync::Arc<T>>, b: &Option<std::sync::Arc<T>>) -> bool {
425 match (a, b) {
426 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
427 (None, None) => true,
428 _ => false,
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use std::collections::HashSet;
435 use std::sync::Arc;
436
437 use ppe::praxis_policy_core::extensions::agent::AgentExtension;
438 use ppe::praxis_policy_core::extensions::monotonic::MonotonicSet;
439
440 use super::*;
441
442 #[test]
443 fn secret_assertion_provenance_survives_python_header_round_trips() {
444 use ppe::praxis_policy_core::assertions::config::OnMissing;
445 use ppe::praxis_policy_core::assertions::resolved::{
446 ResolvedContract, ResolvedHeader, ResolvedSource,
447 };
448 use ppe::praxis_policy_core::assertions::source::SourcePath;
449 use ppe::praxis_policy_core::assertions::{AssertionLevel, Direction, apply};
450
451 let secret = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
453 let contract = ResolvedContract {
454 headers: vec![ResolvedHeader {
455 name: "X-Request-Id".into(),
456 lowercase: "x-request-id".into(),
457 source: ResolvedSource::From(SourcePath::Secret("key".into())),
458 on_missing: OnMissing::Deny,
459 encode: None,
460 declared_in: "global".into(),
461 level: AssertionLevel::Global,
462 overrode: None,
463 }],
464 ..Default::default()
465 };
466 let mut ext = Extensions {
467 http: Some(Arc::new(HttpExtension::default())),
468 ..Default::default()
469 };
470 apply(
471 &contract,
472 &[("X-Request-Id".into(), secret.into())],
473 &mut ext,
474 Direction::Request,
475 );
476 let inbound = ext.http.as_deref().unwrap();
477 let mut outbound = sanitize_http(inbound);
478 assert!(outbound.request_headers.is_empty());
479 outbound.set_request_header("X-Request-Id", secret);
480 let mut worker = HttpExtension::default();
481 worker.set_request_header("x-request-id", secret);
482 worker.set_response_header("Content-Type", "application/json");
483 let returned = returned_http(&worker, Some(inbound));
484 for http in [outbound, returned] {
485 let wire = serde_json::to_string(&http).unwrap();
486 assert!(!wire.contains(secret), "{wire}");
487 assert!(wire.contains("<redacted secret.key>"), "{wire}");
488 }
489 }
490
491 fn http_with_headers() -> HttpExtension {
492 let mut http = HttpExtension::default();
493 http.set_request_header("Authorization", "Bearer secret-token");
494 http.set_request_header("Cookie", "session=abc");
495 http.set_request_header("X-API-Key", "key-123");
496 http.set_request_header("X-Request-Id", "req-1");
497 http.set_request_header("X-Amzn-Oidc-Data", "gateway-jwt");
498 http.set_response_header("Set-Cookie", "s=1");
499 http.set_response_header("Authorization", "Bearer echoed");
500 http.set_response_header("Content-Type", "application/json");
501 http
502 }
503
504 fn security_with_labels(labels: &[&str]) -> SecurityExtension {
505 let set: HashSet<String> = labels.iter().map(|s| s.to_string()).collect();
506 SecurityExtension {
507 labels: MonotonicSet::from_set(set),
508 ..Default::default()
509 }
510 }
511
512 fn populated() -> Extensions {
513 Extensions {
514 agent: Some(Arc::new(AgentExtension::default())),
515 http: Some(Arc::new(http_with_headers())),
516 security: Some(Arc::new(security_with_labels(&["PII"]))),
517 ..Default::default()
518 }
519 }
520
521 #[test]
524 fn present_slots_land_on_the_wire() {
525 let mut task = serde_json::json!({"task_type": "run"});
526 attach_extensions(&mut task, &populated()).expect("serializing a populated view");
527
528 let wire = task
529 .get(EXTENSIONS_FIELD)
530 .expect("the field is attached")
531 .as_object()
532 .expect("the field is an object");
533
534 assert!(wire.contains_key("agent"), "agent slot present");
535 assert!(wire.contains_key("http"), "http slot present");
536 assert!(wire.contains_key("security"), "security slot present");
537 }
538
539 #[test]
540 fn no_unvalidated_request_header_reaches_the_worker() {
541 let mut task = serde_json::json!({});
542 attach_extensions(&mut task, &populated()).expect("serializing");
543
544 let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
545 .as_object()
546 .expect("request_headers is an object");
547 assert!(headers.is_empty(), "raw request headers need validation");
548 }
549
550 #[test]
551 fn no_unvalidated_response_header_reaches_the_worker() {
552 let mut task = serde_json::json!({});
553 attach_extensions(&mut task, &populated()).expect("serializing");
554
555 let headers = task[EXTENSIONS_FIELD]["http"]["response_headers"]
556 .as_object()
557 .expect("response_headers is an object");
558 assert!(headers.is_empty(), "raw response headers need validation");
559 }
560
561 #[test]
562 fn set_cookie_is_stripped_in_both_directions_and_under_any_casing() {
563 let mut http = HttpExtension::default();
566 http.set_response_header("set-cookie", "session=lower");
567 http.set_response_header("SET-COOKIE", "session=upper");
568 http.set_request_header("Set-Cookie", "session=req");
569
570 let sanitized = sanitize_http(&http);
571 assert!(
572 sanitized.response_headers.is_empty(),
573 "no casing of set-cookie survives outbound: {:?}",
574 sanitized.response_headers
575 );
576 assert!(sanitized.request_headers.is_empty());
577
578 let returned = returned_http(&http, None);
579 assert!(
580 returned.response_headers.is_empty() && returned.request_headers.is_empty(),
581 "nor inbound on the return path"
582 );
583 }
584
585 #[test]
586 fn gateway_credential_names_are_stripped_from_worker_edits() {
587 let mut http = HttpExtension::default();
588 http.set_request_header("Proxy-Authorization", "Bearer proxy-secret");
589 http.set_request_header("X-Amzn-Oidc-Data", "gateway-jwt");
590 http.set_response_header("X-Auth-Token", "response-secret");
591 http.set_response_header("X-Trace", "trace-only");
592
593 let returned = returned_http(&http, None);
594 assert!(returned.request_headers.is_empty());
595 assert_eq!(
596 returned.response_headers.get("X-Trace").map(String::as_str),
597 Some("trace-only")
598 );
599 assert_eq!(returned.response_headers.len(), 1);
600 }
601
602 #[test]
603 fn lowercase_sensitive_headers_are_stripped() {
604 let mut http = HttpExtension::default();
607 http.set_request_header("authorization", "Bearer sneaky");
608 http.set_request_header("x-api-key", "k");
609 let extensions = Extensions {
610 http: Some(Arc::new(http)),
611 ..Default::default()
612 };
613
614 let mut task = serde_json::json!({});
615 attach_extensions(&mut task, &extensions).expect("serializing");
616
617 let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
618 .as_object()
619 .expect("request_headers is an object");
620 assert!(
621 headers.is_empty(),
622 "lower-cased sensitive headers must be stripped too, got {headers:?}"
623 );
624 }
625
626 #[test]
627 fn a_filtered_out_slot_is_absent_from_the_wire() {
628 let extensions = Extensions {
631 security: Some(Arc::new(security_with_labels(&["PII"]))),
632 ..Default::default()
633 };
634
635 let mut task = serde_json::json!({});
636 attach_extensions(&mut task, &extensions).expect("serializing");
637
638 let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
639 assert!(
640 !wire.contains_key("agent"),
641 "a slot the capabilities excluded stays excluded"
642 );
643 assert!(wire.contains_key("security"));
644 }
645
646 #[test]
647 fn raw_credentials_never_appear_on_the_wire() {
648 use ppe::praxis_policy_core::extensions::raw_credentials::{
649 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
650 };
651
652 let mut inbound_tokens = HashMap::new();
653 inbound_tokens.insert(
654 TokenRole::User,
655 RawInboundToken::new("super-secret", "Authorization", TokenKind::Jwt),
656 );
657
658 let extensions = Extensions {
659 raw_credentials: Some(Arc::new(RawCredentialsExtension {
660 inbound_tokens,
661 ..Default::default()
662 })),
663 security: Some(Arc::new(security_with_labels(&["PII"]))),
664 ..Default::default()
665 };
666
667 let mut task = serde_json::json!({});
668 attach_extensions(&mut task, &extensions).expect("serializing");
669
670 let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
671 assert!(
672 !wire.contains_key("raw_credentials"),
673 "the credential slot belongs to the credential DTO, not this channel"
674 );
675 let serialized = serde_json::to_string(&task).expect("re-serializing the task");
676 assert!(
677 !serialized.contains("super-secret"),
678 "no token bytes anywhere in the task JSON"
679 );
680 }
681
682 #[test]
683 fn empty_extensions_attach_no_field() {
684 let mut task = serde_json::json!({"task_type": "run"});
685 attach_extensions(&mut task, &Extensions::default()).expect("serializing an empty view");
686 assert!(
687 task.get(EXTENSIONS_FIELD).is_none(),
688 "a view with no visible slots leaves the task shape unchanged"
689 );
690 }
691
692 #[test]
695 fn an_absent_field_means_no_change() {
696 let response = serde_json::json!({"payload": {}});
697 let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
698 assert!(
699 parsed.is_none(),
700 "an omitted field is the documented no-change signal"
701 );
702 }
703
704 #[test]
705 fn an_explicit_null_means_no_change() {
706 let response = serde_json::json!({"modified_extensions": null});
707 let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
708 assert!(parsed.is_none(), "null reads the same as omitted");
709 }
710
711 #[test]
712 fn a_non_object_field_is_a_protocol_error() {
713 let response = serde_json::json!({"modified_extensions": "nope"});
714 let err = parse_returned_extensions(&response, &populated());
715 assert!(err.is_err(), "a non-object field cannot be interpreted");
716 }
717
718 #[test]
719 fn immutable_slots_keep_their_inbound_arcs() {
720 let inbound = populated();
724 let response = serde_json::json!({
725 "modified_extensions": {"custom": {"k": "v"}}
726 });
727
728 let owned = parse_returned_extensions(&response, &inbound)
729 .expect("parsing")
730 .expect("a returned field yields modifications");
731
732 assert!(
733 same_arc(&inbound.agent, &owned.agent),
734 "the agent slot must be the very same Arc the executor issued"
735 );
736 assert!(inbound.validate_immutable(&owned), "the merge accepts it");
737 }
738
739 #[test]
740 fn a_forged_immutable_slot_is_dropped_not_merged() {
741 let inbound = populated();
742 let response = serde_json::json!({
744 "modified_extensions": {
745 "agent": {"agent_id": "forged"},
746 "custom": {"ok": true}
747 }
748 });
749
750 let owned = parse_returned_extensions(&response, &inbound)
751 .expect("parsing")
752 .expect("modifications present");
753
754 assert!(
755 same_arc(&inbound.agent, &owned.agent),
756 "the forged agent edit is dropped, not merged"
757 );
758 assert!(
759 inbound.validate_immutable(&owned),
760 "so the rest of the return still merges cleanly"
761 );
762 assert!(
763 owned.custom.is_some(),
764 "the legitimate custom edit survives"
765 );
766 }
767
768 #[test]
769 fn a_custom_change_is_taken_as_is() {
770 let response = serde_json::json!({
771 "modified_extensions": {"custom": {"verdict": "clean", "score": 3}}
772 });
773 let owned = parse_returned_extensions(&response, &populated())
774 .expect("parsing")
775 .expect("modifications present");
776
777 let custom = owned.custom.expect("custom present");
778 assert_eq!(custom.get("verdict").and_then(Value::as_str), Some("clean"));
779 assert_eq!(custom.get("score").and_then(Value::as_i64), Some(3));
780 }
781
782 #[test]
790 fn a_label_append_without_the_token_is_dropped() {
791 let inbound = populated(); assert!(
793 inbound.labels_write_token.is_none(),
794 "no append_labels capability was granted"
795 );
796
797 let response = serde_json::json!({
798 "modified_extensions": {"security": {"labels": ["PII", "SCANNED"]}}
799 });
800 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
801
802 assert!(
803 parsed.is_none(),
804 "an unauthorized label append yields no modification, so the \
805 pipeline's labels are untouched"
806 );
807 }
808
809 #[test]
810 fn a_label_removal_without_the_token_cannot_strip_labels() {
811 let inbound = populated(); let response = serde_json::json!({
815 "modified_extensions": {"security": {"labels": []}}
816 });
817
818 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
819 assert!(
820 parsed.is_none(),
821 "the removal never reaches the merge, so PII cannot be stripped"
822 );
823 }
824
825 #[test]
826 fn an_http_change_needs_the_write_token() {
827 let inbound = populated();
829 assert!(inbound.http_write_token.is_none());
830
831 let response = serde_json::json!({
832 "modified_extensions": {"http": {"request_headers": {"X-Added": "1"}}}
833 });
834 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
835
836 assert!(
837 parsed.is_none(),
838 "an http edit without the capability is dropped entirely"
839 );
840 }
841
842 #[test]
843 fn a_delegation_change_without_the_token_is_dropped() {
844 let inbound = populated();
845 assert!(inbound.delegation_write_token.is_none());
846
847 let response = serde_json::json!({
848 "modified_extensions": {"delegation": {"hops": []}}
849 });
850 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
851
852 assert!(
853 parsed.is_none(),
854 "the wire cannot add a delegation slot without the capability"
855 );
856 }
857
858 #[test]
859 fn an_unauthorized_gated_write_does_not_suppress_a_legitimate_custom_write() {
860 let inbound = populated();
863 let response = serde_json::json!({
864 "modified_extensions": {
865 "security": {"labels": ["PII", "SCANNED"]},
866 "custom": {"verdict": "clean"}
867 }
868 });
869
870 let owned = parse_returned_extensions(&response, &inbound)
871 .expect("parsing")
872 .expect("the custom write is a real modification");
873
874 assert_eq!(
875 owned.custom.as_ref().expect("custom present")["verdict"],
876 "clean"
877 );
878 let security = owned.security.as_ref().expect("the inbound slot stands");
879 assert!(
880 !security.labels.contains(&"SCANNED".to_string()),
881 "the unauthorized label append is still dropped"
882 );
883 }
884
885 #[test]
888 fn a_custom_write_does_not_carry_a_filtered_security_view_into_the_merge() {
889 let inbound = Extensions {
899 security: Some(Arc::new(security_with_labels(&[]))),
900 ..Default::default()
901 };
902 assert!(inbound.labels_write_token.is_none());
903
904 let response = serde_json::json!({
905 "modified_extensions": {"custom": {"verdict": "clean"}}
906 });
907 let owned = parse_returned_extensions(&response, &inbound)
908 .expect("parsing")
909 .expect("the custom write is a real modification");
910
911 let mut canonical = Extensions {
914 security: Some(Arc::new(security_with_labels(&["PII", "HIPAA"]))),
915 ..Default::default()
916 };
917 canonical.merge_owned(owned);
918
919 let merged = canonical.security.as_ref().expect("slot present");
920 assert!(
921 merged.labels.contains(&"PII".to_string()),
922 "a capability-less custom write must not wipe canonical labels"
923 );
924 assert!(merged.labels.contains(&"HIPAA".to_string()));
925 assert_eq!(
926 canonical.custom.as_ref().expect("custom merged")["verdict"],
927 "clean",
928 "and the legitimate custom write still lands"
929 );
930 }
931
932 #[test]
933 fn the_returned_http_slot_preserves_the_inbound_request_line() {
934 let mut inbound_http = HttpExtension::default();
940 inbound_http.method = Some("POST".into());
941 inbound_http.path = Some("/api/v1/transfer".into());
942 inbound_http.host = Some("bank.internal".into());
943 inbound_http.scheme = Some("https".into());
944
945 let returned_absent = HttpExtension {
947 request_headers: [("X-Scanned".to_string(), "1".to_string())].into(),
948 ..Default::default()
949 };
950 let merged = returned_http(&returned_absent, Some(&inbound_http));
951 assert_eq!(merged.method.as_deref(), Some("POST"));
952 assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
953 assert_eq!(merged.host.as_deref(), Some("bank.internal"));
954 assert_eq!(merged.scheme.as_deref(), Some("https"));
955 assert_eq!(
956 merged.request_headers.get("X-Scanned").map(String::as_str),
957 Some("1")
958 );
959
960 let returned_hostile = HttpExtension {
962 method: Some("GET".into()),
963 path: Some("/healthz".into()),
964 host: Some("evil.example".into()),
965 scheme: Some("http".into()),
966 ..Default::default()
967 };
968 let merged = returned_http(&returned_hostile, Some(&inbound_http));
969 assert_eq!(
970 merged.host.as_deref(),
971 Some("bank.internal"),
972 "the worker cannot re-point the validated authority"
973 );
974 assert_eq!(merged.method.as_deref(), Some("POST"));
975 assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
976 assert_eq!(merged.scheme.as_deref(), Some("https"));
977 }
978
979 #[test]
980 fn a_returned_delegation_chain_is_validated_not_accepted_blind() {
981 use ppe::praxis_policy_core::extensions::delegation::DelegationHop;
986
987 let hop = |id: &str, scopes: Vec<&str>| DelegationHop {
988 subject_id: id.into(),
989 scopes_granted: scopes.into_iter().map(str::to_string).collect(),
990 ..Default::default()
991 };
992
993 let canonical = vec![
994 hop("user-1", vec!["read_hr"]),
995 hop("svc-a", vec!["read_hr"]),
996 ];
997
998 assert!(
999 chain_extends(&canonical, &canonical),
1000 "an unchanged chain is a valid (empty) append"
1001 );
1002 let mut appended = canonical.clone();
1003 appended.push(hop("svc-b", vec!["read_hr"]));
1004 assert!(
1005 chain_extends(&canonical, &appended),
1006 "a real append is valid"
1007 );
1008
1009 assert!(
1010 !chain_extends(&canonical, &canonical[..1]),
1011 "truncation drops the hop that recorded a narrowing — not an append"
1012 );
1013 let mut widened = canonical.clone();
1014 widened[0].scopes_granted = vec!["admin".into()];
1015 assert!(
1016 !chain_extends(&canonical, &widened),
1017 "widening an existing hop's scopes is not an append"
1018 );
1019 let mut reseated = canonical.clone();
1020 reseated[1].subject_id = "attacker".into();
1021 assert!(
1022 !chain_extends(&canonical, &reseated),
1023 "rewriting an existing hop's subject is not an append"
1024 );
1025 }
1026
1027 #[test]
1028 fn malformed_slot_json_is_an_error_not_a_silent_drop() {
1029 let response = serde_json::json!({
1030 "modified_extensions": {"security": "not-an-object"}
1031 });
1032 let err = parse_returned_extensions(&response, &populated());
1033 assert!(
1034 err.is_err(),
1035 "unparseable slot JSON must surface, not read as no-change"
1036 );
1037 }
1038}