1use std::collections::HashMap;
59
60use cpex_core::extensions::container::{chain_extends, OwnedExtensions};
61use cpex_core::extensions::delegation::DelegationExtension;
62use cpex_core::extensions::guarded::Guarded;
63use cpex_core::extensions::http::HttpExtension;
64use cpex_core::extensions::security::SecurityExtension;
65use cpex_core::extensions::Extensions;
66use serde_json::Value;
67use tracing::warn;
68
69use crate::error::HostError;
70
71pub const EXTENSIONS_FIELD: &str = "extensions";
75
76pub const MODIFIED_EXTENSIONS_FIELD: &str = "modified_extensions";
86
87const SENSITIVE_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie", "x-api-key"];
97
98fn is_sensitive(name: &str) -> bool {
100 SENSITIVE_HEADERS
101 .iter()
102 .any(|s| name.eq_ignore_ascii_case(s))
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 {
121 HttpExtension {
122 request_headers: strip_sensitive(&http.request_headers),
123 response_headers: strip_sensitive(&http.response_headers),
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 response_headers: strip_sensitive(&returned.response_headers),
148 method: inbound.and_then(|h| h.method.clone()),
149 path: inbound.and_then(|h| h.path.clone()),
150 host: inbound.and_then(|h| h.host.clone()),
151 scheme: inbound.and_then(|h| h.scheme.clone()),
152 }
153}
154
155pub fn attach_extensions(task: &mut Value, extensions: &Extensions) -> Result<(), HostError> {
169 let wire = to_wire(extensions)?;
170
171 if wire.as_object().is_none_or(serde_json::Map::is_empty) {
175 return Ok(());
176 }
177
178 let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
179 message: "task must be a JSON object to carry extensions".into(),
180 })?;
181 object.insert(EXTENSIONS_FIELD.to_string(), wire);
182
183 Ok(())
184}
185
186fn to_wire(extensions: &Extensions) -> Result<Value, HostError> {
193 let mut map = serde_json::Map::new();
194
195 let mut put = |key: &str, value: Result<Value, serde_json::Error>| -> Result<(), HostError> {
196 let value = value.map_err(|e| HostError::Protocol {
197 message: format!("could not serialize the '{key}' extension for the worker: {e}"),
198 })?;
199 map.insert(key.to_string(), value);
200 Ok(())
201 };
202
203 if let Some(request) = &extensions.request {
204 put("request", serde_json::to_value(request))?;
205 }
206 if let Some(agent) = &extensions.agent {
207 put("agent", serde_json::to_value(agent))?;
208 }
209 if let Some(http) = &extensions.http {
210 put("http", serde_json::to_value(sanitize_http(http)))?;
211 }
212 if let Some(security) = &extensions.security {
213 put("security", serde_json::to_value(security))?;
214 }
215 if let Some(delegation) = &extensions.delegation {
216 put("delegation", serde_json::to_value(delegation))?;
217 }
218 if let Some(mcp) = &extensions.mcp {
219 put("mcp", serde_json::to_value(mcp))?;
220 }
221 if let Some(completion) = &extensions.completion {
222 put("completion", serde_json::to_value(completion))?;
223 }
224 if let Some(provenance) = &extensions.provenance {
225 put("provenance", serde_json::to_value(provenance))?;
226 }
227 if let Some(llm) = &extensions.llm {
228 put("llm", serde_json::to_value(llm))?;
229 }
230 if let Some(framework) = &extensions.framework {
231 put("framework", serde_json::to_value(framework))?;
232 }
233 if let Some(meta) = &extensions.meta {
234 put("meta", serde_json::to_value(meta))?;
235 }
236 if let Some(custom) = &extensions.custom {
237 put("custom", serde_json::to_value(custom))?;
238 }
239 Ok(Value::Object(map))
242}
243
244pub fn parse_returned_extensions(
263 response: &Value,
264 inbound: &Extensions,
265) -> Result<Option<OwnedExtensions>, HostError> {
266 let Some(field) = response.get(MODIFIED_EXTENSIONS_FIELD) else {
267 return Ok(None);
268 };
269 owned_from_returned_slot(field, inbound)
270}
271
272pub fn owned_from_returned_slot(
278 field: &Value,
279 inbound: &Extensions,
280) -> Result<Option<OwnedExtensions>, HostError> {
281 if field.is_null() {
284 return Ok(None);
285 }
286
287 let object = field.as_object().ok_or_else(|| HostError::Protocol {
288 message: format!(
289 "the worker returned a '{MODIFIED_EXTENSIONS_FIELD}' field that is not a JSON object"
290 ),
291 })?;
292
293 let mut owned = inbound.cow_copy();
296
297 let mut applied = false;
302
303 fn slot<T: serde::de::DeserializeOwned>(
304 object: &serde_json::Map<String, Value>,
305 key: &str,
306 ) -> Result<Option<T>, HostError> {
307 match object.get(key) {
308 None | Some(Value::Null) => Ok(None),
309 Some(value) => serde_json::from_value(value.clone())
310 .map(Some)
311 .map_err(|e| HostError::Protocol {
312 message: format!(
313 "could not deserialize the '{key}' extension returned by the worker: {e}"
314 ),
315 }),
316 }
317 }
318
319 if let Some(http) = slot::<HttpExtension>(object, "http")? {
338 if inbound.http_write_token.is_some() {
339 owned.http = Some(Guarded::new(returned_http(&http, inbound.http.as_deref())));
342 applied = true;
343 }
344 }
345
346 if let Some(security) = slot::<SecurityExtension>(object, "security")? {
353 if inbound.labels_write_token.is_some() {
354 let mut merged = inbound
355 .security
356 .as_deref()
357 .cloned()
358 .unwrap_or_else(SecurityExtension::default);
359 for label in security.labels.iter() {
362 merged.add_label(label.clone());
363 }
364 owned.security = Some(merged);
365 applied = true;
366 }
367 }
368
369 if let Some(delegation) = slot::<DelegationExtension>(object, "delegation")? {
376 if inbound.delegation_write_token.is_some() {
377 let inbound_chain = inbound
378 .delegation
379 .as_deref()
380 .map(|d| d.chain.as_slice())
381 .unwrap_or_default();
382 if chain_extends(inbound_chain, &delegation.chain) {
383 owned.delegation = Some(delegation);
384 applied = true;
385 } else {
386 warn!(
387 inbound_hops = inbound_chain.len(),
388 returned_hops = delegation.chain.len(),
389 "dropping a returned delegation chain that does not extend the \
390 one the plugin was given — an append-only chain cannot be \
391 shortened or rewritten"
392 );
393 }
394 }
395 }
396
397 if let Some(custom) = slot::<HashMap<String, Value>>(object, "custom")? {
402 owned.custom = Some(custom);
403 applied = true;
404 }
405
406 if !applied {
410 return Ok(None);
411 }
412
413 Ok(Some(owned))
414}
415
416#[cfg(test)]
420fn same_arc<T>(a: &Option<std::sync::Arc<T>>, b: &Option<std::sync::Arc<T>>) -> bool {
421 match (a, b) {
422 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
423 (None, None) => true,
424 _ => false,
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use std::collections::HashSet;
431 use std::sync::Arc;
432
433 use cpex_core::extensions::agent::AgentExtension;
434 use cpex_core::extensions::monotonic::MonotonicSet;
435
436 use super::*;
437
438 fn http_with_headers() -> HttpExtension {
439 let mut http = HttpExtension::default();
440 http.set_request_header("Authorization", "Bearer secret-token");
441 http.set_request_header("Cookie", "session=abc");
442 http.set_request_header("X-API-Key", "key-123");
443 http.set_request_header("X-Request-Id", "req-1");
444 http.set_response_header("Set-Cookie", "s=1");
445 http.set_response_header("Authorization", "Bearer echoed");
446 http.set_response_header("Content-Type", "application/json");
447 http
448 }
449
450 fn security_with_labels(labels: &[&str]) -> SecurityExtension {
451 let set: HashSet<String> = labels.iter().map(|s| s.to_string()).collect();
452 SecurityExtension {
453 labels: MonotonicSet::from_set(set),
454 ..Default::default()
455 }
456 }
457
458 fn populated() -> Extensions {
459 Extensions {
460 agent: Some(Arc::new(AgentExtension::default())),
461 http: Some(Arc::new(http_with_headers())),
462 security: Some(Arc::new(security_with_labels(&["PII"]))),
463 ..Default::default()
464 }
465 }
466
467 #[test]
470 fn present_slots_land_on_the_wire() {
471 let mut task = serde_json::json!({"task_type": "run"});
472 attach_extensions(&mut task, &populated()).expect("serializing a populated view");
473
474 let wire = task
475 .get(EXTENSIONS_FIELD)
476 .expect("the field is attached")
477 .as_object()
478 .expect("the field is an object");
479
480 assert!(wire.contains_key("agent"), "agent slot present");
481 assert!(wire.contains_key("http"), "http slot present");
482 assert!(wire.contains_key("security"), "security slot present");
483 }
484
485 fn assert_absent(headers: &serde_json::Map<String, Value>, names: &[&str]) {
494 for name in names {
495 assert!(
496 !headers.keys().any(|k| k.eq_ignore_ascii_case(name)),
497 "header '{name}' must not cross the process boundary; got {:?}",
498 headers.keys().collect::<Vec<_>>()
499 );
500 }
501 }
502
503 #[test]
504 fn sensitive_request_headers_are_stripped_but_others_survive() {
505 let mut task = serde_json::json!({});
506 attach_extensions(&mut task, &populated()).expect("serializing");
507
508 let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
509 .as_object()
510 .expect("request_headers is an object")
511 .clone();
512
513 assert_absent(&headers, &["Authorization", "Cookie", "X-API-Key"]);
514 assert_eq!(
515 headers.get("X-Request-Id").and_then(Value::as_str),
516 Some("req-1"),
517 "a non-sensitive header is preserved verbatim"
518 );
519 }
520
521 #[test]
522 fn sensitive_response_headers_are_stripped_too() {
523 let mut task = serde_json::json!({});
524 attach_extensions(&mut task, &populated()).expect("serializing");
525
526 let headers = task[EXTENSIONS_FIELD]["http"]["response_headers"]
527 .as_object()
528 .expect("response_headers is an object")
529 .clone();
530
531 assert_absent(&headers, &["Set-Cookie", "Authorization"]);
534 assert_eq!(
535 headers.get("Content-Type").and_then(Value::as_str),
536 Some("application/json"),
537 );
538 }
539
540 #[test]
541 fn set_cookie_is_stripped_in_both_directions_and_under_any_casing() {
542 let mut http = HttpExtension::default();
545 http.set_response_header("set-cookie", "session=lower");
546 http.set_response_header("SET-COOKIE", "session=upper");
547 http.set_request_header("Set-Cookie", "session=req");
548
549 let sanitized = sanitize_http(&http);
550 assert!(
551 sanitized.response_headers.is_empty(),
552 "no casing of set-cookie survives outbound: {:?}",
553 sanitized.response_headers
554 );
555 assert!(sanitized.request_headers.is_empty());
556
557 let returned = returned_http(&http, None);
558 assert!(
559 returned.response_headers.is_empty() && returned.request_headers.is_empty(),
560 "nor inbound on the return path"
561 );
562 }
563
564 #[test]
565 fn lowercase_sensitive_headers_are_stripped() {
566 let mut http = HttpExtension::default();
569 http.set_request_header("authorization", "Bearer sneaky");
570 http.set_request_header("x-api-key", "k");
571 let extensions = Extensions {
572 http: Some(Arc::new(http)),
573 ..Default::default()
574 };
575
576 let mut task = serde_json::json!({});
577 attach_extensions(&mut task, &extensions).expect("serializing");
578
579 let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
580 .as_object()
581 .expect("request_headers is an object");
582 assert!(
583 headers.is_empty(),
584 "lower-cased sensitive headers must be stripped too, got {headers:?}"
585 );
586 }
587
588 #[test]
589 fn a_filtered_out_slot_is_absent_from_the_wire() {
590 let extensions = Extensions {
593 security: Some(Arc::new(security_with_labels(&["PII"]))),
594 ..Default::default()
595 };
596
597 let mut task = serde_json::json!({});
598 attach_extensions(&mut task, &extensions).expect("serializing");
599
600 let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
601 assert!(
602 !wire.contains_key("agent"),
603 "a slot the capabilities excluded stays excluded"
604 );
605 assert!(wire.contains_key("security"));
606 }
607
608 #[test]
609 fn raw_credentials_never_appear_on_the_wire() {
610 use cpex_core::extensions::raw_credentials::{
611 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
612 };
613
614 let mut inbound_tokens = HashMap::new();
615 inbound_tokens.insert(
616 TokenRole::User,
617 RawInboundToken::new("super-secret", "Authorization", TokenKind::Jwt),
618 );
619
620 let extensions = Extensions {
621 raw_credentials: Some(Arc::new(RawCredentialsExtension {
622 inbound_tokens,
623 ..Default::default()
624 })),
625 security: Some(Arc::new(security_with_labels(&["PII"]))),
626 ..Default::default()
627 };
628
629 let mut task = serde_json::json!({});
630 attach_extensions(&mut task, &extensions).expect("serializing");
631
632 let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
633 assert!(
634 !wire.contains_key("raw_credentials"),
635 "the credential slot belongs to the credential DTO, not this channel"
636 );
637 let serialized = serde_json::to_string(&task).expect("re-serializing the task");
638 assert!(
639 !serialized.contains("super-secret"),
640 "no token bytes anywhere in the task JSON"
641 );
642 }
643
644 #[test]
645 fn empty_extensions_attach_no_field() {
646 let mut task = serde_json::json!({"task_type": "run"});
647 attach_extensions(&mut task, &Extensions::default()).expect("serializing an empty view");
648 assert!(
649 task.get(EXTENSIONS_FIELD).is_none(),
650 "a view with no visible slots leaves the task shape unchanged"
651 );
652 }
653
654 #[test]
657 fn an_absent_field_means_no_change() {
658 let response = serde_json::json!({"payload": {}});
659 let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
660 assert!(
661 parsed.is_none(),
662 "an omitted field is the documented no-change signal"
663 );
664 }
665
666 #[test]
667 fn an_explicit_null_means_no_change() {
668 let response = serde_json::json!({"modified_extensions": null});
669 let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
670 assert!(parsed.is_none(), "null reads the same as omitted");
671 }
672
673 #[test]
674 fn a_non_object_field_is_a_protocol_error() {
675 let response = serde_json::json!({"modified_extensions": "nope"});
676 let err = parse_returned_extensions(&response, &populated());
677 assert!(err.is_err(), "a non-object field cannot be interpreted");
678 }
679
680 #[test]
681 fn immutable_slots_keep_their_inbound_arcs() {
682 let inbound = populated();
686 let response = serde_json::json!({
687 "modified_extensions": {"custom": {"k": "v"}}
688 });
689
690 let owned = parse_returned_extensions(&response, &inbound)
691 .expect("parsing")
692 .expect("a returned field yields modifications");
693
694 assert!(
695 same_arc(&inbound.agent, &owned.agent),
696 "the agent slot must be the very same Arc the executor issued"
697 );
698 assert!(inbound.validate_immutable(&owned), "the merge accepts it");
699 }
700
701 #[test]
702 fn a_forged_immutable_slot_is_dropped_not_merged() {
703 let inbound = populated();
704 let response = serde_json::json!({
706 "modified_extensions": {
707 "agent": {"agent_id": "forged"},
708 "custom": {"ok": true}
709 }
710 });
711
712 let owned = parse_returned_extensions(&response, &inbound)
713 .expect("parsing")
714 .expect("modifications present");
715
716 assert!(
717 same_arc(&inbound.agent, &owned.agent),
718 "the forged agent edit is dropped, not merged"
719 );
720 assert!(
721 inbound.validate_immutable(&owned),
722 "so the rest of the return still merges cleanly"
723 );
724 assert!(
725 owned.custom.is_some(),
726 "the legitimate custom edit survives"
727 );
728 }
729
730 #[test]
731 fn a_custom_change_is_taken_as_is() {
732 let response = serde_json::json!({
733 "modified_extensions": {"custom": {"verdict": "clean", "score": 3}}
734 });
735 let owned = parse_returned_extensions(&response, &populated())
736 .expect("parsing")
737 .expect("modifications present");
738
739 let custom = owned.custom.expect("custom present");
740 assert_eq!(custom.get("verdict").and_then(Value::as_str), Some("clean"));
741 assert_eq!(custom.get("score").and_then(Value::as_i64), Some(3));
742 }
743
744 #[test]
752 fn a_label_append_without_the_token_is_dropped() {
753 let inbound = populated(); assert!(
755 inbound.labels_write_token.is_none(),
756 "no append_labels capability was granted"
757 );
758
759 let response = serde_json::json!({
760 "modified_extensions": {"security": {"labels": ["PII", "SCANNED"]}}
761 });
762 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
763
764 assert!(
765 parsed.is_none(),
766 "an unauthorized label append yields no modification, so the \
767 pipeline's labels are untouched"
768 );
769 }
770
771 #[test]
772 fn a_label_removal_without_the_token_cannot_strip_labels() {
773 let inbound = populated(); let response = serde_json::json!({
777 "modified_extensions": {"security": {"labels": []}}
778 });
779
780 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
781 assert!(
782 parsed.is_none(),
783 "the removal never reaches the merge, so PII cannot be stripped"
784 );
785 }
786
787 #[test]
788 fn an_http_change_needs_the_write_token() {
789 let inbound = populated();
791 assert!(inbound.http_write_token.is_none());
792
793 let response = serde_json::json!({
794 "modified_extensions": {"http": {"request_headers": {"X-Added": "1"}}}
795 });
796 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
797
798 assert!(
799 parsed.is_none(),
800 "an http edit without the capability is dropped entirely"
801 );
802 }
803
804 #[test]
805 fn a_delegation_change_without_the_token_is_dropped() {
806 let inbound = populated();
807 assert!(inbound.delegation_write_token.is_none());
808
809 let response = serde_json::json!({
810 "modified_extensions": {"delegation": {"hops": []}}
811 });
812 let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
813
814 assert!(
815 parsed.is_none(),
816 "the wire cannot add a delegation slot without the capability"
817 );
818 }
819
820 #[test]
821 fn an_unauthorized_gated_write_does_not_suppress_a_legitimate_custom_write() {
822 let inbound = populated();
825 let response = serde_json::json!({
826 "modified_extensions": {
827 "security": {"labels": ["PII", "SCANNED"]},
828 "custom": {"verdict": "clean"}
829 }
830 });
831
832 let owned = parse_returned_extensions(&response, &inbound)
833 .expect("parsing")
834 .expect("the custom write is a real modification");
835
836 assert_eq!(
837 owned.custom.as_ref().expect("custom present")["verdict"],
838 "clean"
839 );
840 let security = owned.security.as_ref().expect("the inbound slot stands");
841 assert!(
842 !security.labels.contains(&"SCANNED".to_string()),
843 "the unauthorized label append is still dropped"
844 );
845 }
846
847 #[test]
850 fn a_custom_write_does_not_carry_a_filtered_security_view_into_the_merge() {
851 let inbound = Extensions {
861 security: Some(Arc::new(security_with_labels(&[]))),
862 ..Default::default()
863 };
864 assert!(inbound.labels_write_token.is_none());
865
866 let response = serde_json::json!({
867 "modified_extensions": {"custom": {"verdict": "clean"}}
868 });
869 let owned = parse_returned_extensions(&response, &inbound)
870 .expect("parsing")
871 .expect("the custom write is a real modification");
872
873 let mut canonical = Extensions {
876 security: Some(Arc::new(security_with_labels(&["PII", "HIPAA"]))),
877 ..Default::default()
878 };
879 canonical.merge_owned(owned);
880
881 let merged = canonical.security.as_ref().expect("slot present");
882 assert!(
883 merged.labels.contains(&"PII".to_string()),
884 "a capability-less custom write must not wipe canonical labels"
885 );
886 assert!(merged.labels.contains(&"HIPAA".to_string()));
887 assert_eq!(
888 canonical.custom.as_ref().expect("custom merged")["verdict"],
889 "clean",
890 "and the legitimate custom write still lands"
891 );
892 }
893
894 #[test]
895 fn the_returned_http_slot_preserves_the_inbound_request_line() {
896 let mut inbound_http = HttpExtension::default();
902 inbound_http.method = Some("POST".into());
903 inbound_http.path = Some("/api/v1/transfer".into());
904 inbound_http.host = Some("bank.internal".into());
905 inbound_http.scheme = Some("https".into());
906
907 let returned_absent = HttpExtension {
909 request_headers: [("X-Scanned".to_string(), "1".to_string())].into(),
910 ..Default::default()
911 };
912 let merged = returned_http(&returned_absent, Some(&inbound_http));
913 assert_eq!(merged.method.as_deref(), Some("POST"));
914 assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
915 assert_eq!(merged.host.as_deref(), Some("bank.internal"));
916 assert_eq!(merged.scheme.as_deref(), Some("https"));
917 assert_eq!(
918 merged.request_headers.get("X-Scanned").map(String::as_str),
919 Some("1")
920 );
921
922 let returned_hostile = HttpExtension {
924 method: Some("GET".into()),
925 path: Some("/healthz".into()),
926 host: Some("evil.example".into()),
927 scheme: Some("http".into()),
928 ..Default::default()
929 };
930 let merged = returned_http(&returned_hostile, Some(&inbound_http));
931 assert_eq!(
932 merged.host.as_deref(),
933 Some("bank.internal"),
934 "the worker cannot re-point the validated authority"
935 );
936 assert_eq!(merged.method.as_deref(), Some("POST"));
937 assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
938 assert_eq!(merged.scheme.as_deref(), Some("https"));
939 }
940
941 #[test]
942 fn a_returned_delegation_chain_is_validated_not_accepted_blind() {
943 use cpex_core::extensions::delegation::DelegationHop;
948
949 let hop = |id: &str, scopes: Vec<&str>| DelegationHop {
950 subject_id: id.into(),
951 scopes_granted: scopes.into_iter().map(str::to_string).collect(),
952 ..Default::default()
953 };
954
955 let canonical = vec![
956 hop("user-1", vec!["read_hr"]),
957 hop("svc-a", vec!["read_hr"]),
958 ];
959
960 assert!(
961 chain_extends(&canonical, &canonical),
962 "an unchanged chain is a valid (empty) append"
963 );
964 let mut appended = canonical.clone();
965 appended.push(hop("svc-b", vec!["read_hr"]));
966 assert!(
967 chain_extends(&canonical, &appended),
968 "a real append is valid"
969 );
970
971 assert!(
972 !chain_extends(&canonical, &canonical[..1]),
973 "truncation drops the hop that recorded a narrowing — not an append"
974 );
975 let mut widened = canonical.clone();
976 widened[0].scopes_granted = vec!["admin".into()];
977 assert!(
978 !chain_extends(&canonical, &widened),
979 "widening an existing hop's scopes is not an append"
980 );
981 let mut reseated = canonical.clone();
982 reseated[1].subject_id = "attacker".into();
983 assert!(
984 !chain_extends(&canonical, &reseated),
985 "rewriting an existing hop's subject is not an append"
986 );
987 }
988
989 #[test]
990 fn malformed_slot_json_is_an_error_not_a_silent_drop() {
991 let response = serde_json::json!({
992 "modified_extensions": {"security": "not-an-object"}
993 });
994 let err = parse_returned_extensions(&response, &populated());
995 assert!(
996 err.is_err(),
997 "unparseable slot JSON must surface, not read as no-change"
998 );
999 }
1000}