1use ppe::praxis_policy_core::cmf::MessagePayload;
27use ppe::praxis_policy_core::cmf::constants::{
28 HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
29 HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
30 HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
31};
32use ppe::praxis_policy_core::delegation::{DelegationPayload, HOOK_TOKEN_DELEGATE};
33use ppe::praxis_policy_core::error::PluginViolation;
34use ppe::praxis_policy_core::executor::ErasedResultFields;
35use ppe::praxis_policy_core::hooks::payload::PluginPayload;
36use ppe::praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload};
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40use crate::error::HostError;
41use crate::legacy::payloads::{
42 IdentityResolvePayload, PromptPostFetchPayload, PromptPreFetchPayload,
43 ResourcePostFetchPayload, ResourcePreFetchPayload, TokenDelegatePayload, ToolPostInvokePayload,
44 ToolPreInvokePayload,
45};
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GenericPayload {
56 pub value: Value,
57}
58
59ppe::praxis_policy_core::impl_plugin_payload!(GenericPayload);
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PayloadKind {
64 CmfMessage,
66 ToolPreInvoke,
67 ToolPostInvoke,
68 PromptPreFetch,
69 PromptPostFetch,
70 ResourcePreFetch,
71 ResourcePostFetch,
72 IdentityResolve,
74 TokenDelegate,
76 Generic,
78}
79
80struct HookRoute {
83 dispatched: &'static str,
84 kind: PayloadKind,
85 worker: &'static str,
86}
87
88const HOOK_ROUTES: &[HookRoute] = &[
109 route(
110 HOOK_CMF_TOOL_PRE_INVOKE,
111 PayloadKind::CmfMessage,
112 HOOK_CMF_TOOL_PRE_INVOKE,
113 ),
114 route(
115 HOOK_CMF_TOOL_POST_INVOKE,
116 PayloadKind::CmfMessage,
117 HOOK_CMF_TOOL_POST_INVOKE,
118 ),
119 route(
120 HOOK_CMF_LLM_INPUT,
121 PayloadKind::CmfMessage,
122 HOOK_CMF_LLM_INPUT,
123 ),
124 route(
125 HOOK_CMF_LLM_OUTPUT,
126 PayloadKind::CmfMessage,
127 HOOK_CMF_LLM_OUTPUT,
128 ),
129 route(
130 HOOK_CMF_PROMPT_PRE_INVOKE,
131 PayloadKind::CmfMessage,
132 "cmf.prompt_pre_fetch",
133 ),
134 route(
135 HOOK_CMF_PROMPT_POST_INVOKE,
136 PayloadKind::CmfMessage,
137 "cmf.prompt_post_fetch",
138 ),
139 route(
140 HOOK_CMF_RESOURCE_PRE_FETCH,
141 PayloadKind::CmfMessage,
142 HOOK_CMF_RESOURCE_PRE_FETCH,
143 ),
144 route(
145 HOOK_CMF_RESOURCE_POST_FETCH,
146 PayloadKind::CmfMessage,
147 HOOK_CMF_RESOURCE_POST_FETCH,
148 ),
149 route(
150 HOOK_IDENTITY_RESOLVE,
151 PayloadKind::IdentityResolve,
152 "identity_resolve",
153 ),
154 route(
155 HOOK_TOKEN_DELEGATE,
156 PayloadKind::TokenDelegate,
157 "token_delegate",
158 ),
159 route(
160 "tool_pre_invoke",
161 PayloadKind::ToolPreInvoke,
162 "tool_pre_invoke",
163 ),
164 route(
165 "tool_post_invoke",
166 PayloadKind::ToolPostInvoke,
167 "tool_post_invoke",
168 ),
169 route(
170 "prompt_pre_fetch",
171 PayloadKind::PromptPreFetch,
172 "prompt_pre_fetch",
173 ),
174 route(
175 "prompt_post_fetch",
176 PayloadKind::PromptPostFetch,
177 "prompt_post_fetch",
178 ),
179 route(
180 "resource_pre_fetch",
181 PayloadKind::ResourcePreFetch,
182 "resource_pre_fetch",
183 ),
184 route(
185 "resource_post_fetch",
186 PayloadKind::ResourcePostFetch,
187 "resource_post_fetch",
188 ),
189];
190
191const fn route(dispatched: &'static str, kind: PayloadKind, worker: &'static str) -> HookRoute {
192 HookRoute {
193 dispatched,
194 kind,
195 worker,
196 }
197}
198
199fn lookup_route(hook_name: &str) -> Option<&'static HookRoute> {
200 HOOK_ROUTES.iter().find(|r| r.dispatched == hook_name)
201}
202
203pub fn payload_kind_for_hook(hook_name: &str) -> PayloadKind {
208 lookup_route(hook_name).map_or(PayloadKind::Generic, |r| r.kind)
209}
210
211pub fn worker_hook_name(hook_name: &str) -> &str {
217 lookup_route(hook_name).map_or(hook_name, |r| r.worker)
218}
219
220pub fn is_credential_hook(hook_name: &str) -> bool {
228 matches!(
229 payload_kind_for_hook(hook_name),
230 PayloadKind::IdentityResolve | PayloadKind::TokenDelegate
231 )
232}
233
234pub fn serialize_payload(payload: &dyn PluginPayload) -> Result<Value, HostError> {
245 let any = payload.as_any();
246
247 macro_rules! try_downcast {
250 ($($ty:ty),+ $(,)?) => {
251 $(
252 if let Some(concrete) = any.downcast_ref::<$ty>() {
253 return serde_json::to_value(concrete).map_err(|e| HostError::Protocol {
254 message: format!(
255 "could not serialize a {} payload: {e}",
256 stringify!($ty)
257 ),
258 });
259 }
260 )+
261 };
262 }
263
264 if let Some(identity) = any.downcast_ref::<IdentityPayload>() {
269 return crate::credential_payloads::identity_to_worker(identity);
270 }
271 if let Some(delegation) = any.downcast_ref::<DelegationPayload>() {
272 return crate::credential_payloads::delegation_to_worker(delegation);
273 }
274
275 try_downcast!(
276 MessagePayload,
277 ToolPreInvokePayload,
278 ToolPostInvokePayload,
279 PromptPreFetchPayload,
280 PromptPostFetchPayload,
281 ResourcePreFetchPayload,
282 ResourcePostFetchPayload,
283 IdentityResolvePayload,
284 TokenDelegatePayload,
285 );
286
287 if let Some(generic) = any.downcast_ref::<GenericPayload>() {
290 return Ok(generic.value.clone());
291 }
292
293 Err(HostError::Protocol {
294 message: "payload type is not one this host can forward to a Python worker \
295 (expected a CMF message payload, a PPE identity or delegation payload, a legacy typed \
296 payload, or the generic payload)"
297 .into(),
298 })
299}
300
301pub fn deserialize_payload(
307 hook_name: &str,
308 value: Value,
309) -> Result<Box<dyn PluginPayload>, HostError> {
310 fn parse<T>(value: Value, hook_name: &str) -> Result<Box<dyn PluginPayload>, HostError>
311 where
312 T: PluginPayload + serde::de::DeserializeOwned,
313 {
314 serde_json::from_value::<T>(value)
315 .map(|p| Box::new(p) as Box<dyn PluginPayload>)
316 .map_err(|e| HostError::Protocol {
317 message: format!(
318 "worker returned a modified payload that does not match hook '{hook_name}': {e}"
319 ),
320 })
321 }
322
323 match payload_kind_for_hook(hook_name) {
324 PayloadKind::CmfMessage => parse::<MessagePayload>(value, hook_name),
325 PayloadKind::ToolPreInvoke => parse::<ToolPreInvokePayload>(value, hook_name),
326 PayloadKind::ToolPostInvoke => parse::<ToolPostInvokePayload>(value, hook_name),
327 PayloadKind::PromptPreFetch => parse::<PromptPreFetchPayload>(value, hook_name),
328 PayloadKind::PromptPostFetch => parse::<PromptPostFetchPayload>(value, hook_name),
329 PayloadKind::ResourcePreFetch => parse::<ResourcePreFetchPayload>(value, hook_name),
330 PayloadKind::ResourcePostFetch => parse::<ResourcePostFetchPayload>(value, hook_name),
331 PayloadKind::IdentityResolve => parse::<IdentityResolvePayload>(value, hook_name),
332 PayloadKind::TokenDelegate => parse::<TokenDelegatePayload>(value, hook_name),
333 PayloadKind::Generic => Ok(Box::new(GenericPayload { value })),
334 }
335}
336
337pub fn response_to_result(
350 hook_name: &str,
351 response: Value,
352 inbound: &ppe::praxis_policy_core::extensions::Extensions,
353) -> Result<ErasedResultFields, HostError> {
354 let continue_processing = response
355 .get("continue_processing")
356 .and_then(Value::as_bool)
357 .unwrap_or(true);
358
359 let violation = match response.get("violation") {
360 Some(Value::Null) | None => None,
361 Some(raw) => Some(parse_violation(raw.clone())?),
362 };
363
364 let modified_payload = match response.get("modified_payload") {
365 Some(Value::Null) | None => None,
366 Some(raw) => Some(deserialize_payload(hook_name, raw.clone())?),
367 };
368
369 let modified_extensions = match response.get(crate::extensions::MODIFIED_EXTENSIONS_FIELD) {
370 Some(Value::Null) | None => None,
371 Some(raw) => crate::extensions::owned_from_returned_slot(raw, inbound)?,
372 };
373
374 Ok(ErasedResultFields {
375 continue_processing,
376 modified_payload,
377 modified_extensions,
378 violation,
379 })
380}
381
382fn parse_violation(raw: Value) -> Result<PluginViolation, HostError> {
390 let obj = raw.as_object().ok_or_else(|| HostError::Protocol {
391 message: "worker returned a violation that is not a JSON object".into(),
392 })?;
393
394 let code = obj.get("code").and_then(Value::as_str).unwrap_or_default();
395 let reason = obj
396 .get("reason")
397 .and_then(Value::as_str)
398 .unwrap_or_default();
399
400 let mut violation = PluginViolation::new(code, reason);
401 violation.description = obj
402 .get("description")
403 .and_then(Value::as_str)
404 .map(str::to_string);
405
406 if let Some(details) = obj.get("details").and_then(Value::as_object) {
407 violation.details = details.clone().into_iter().collect();
408 }
409
410 violation.proto_error_code = obj
413 .get("mcp_error_code")
414 .or_else(|| obj.get("proto_error_code"))
415 .and_then(Value::as_i64);
416
417 Ok(violation)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::collections::HashMap;
424
425 fn no_inbound() -> ppe::praxis_policy_core::extensions::Extensions {
433 ppe::praxis_policy_core::extensions::Extensions::default()
434 }
435
436 #[test]
439 fn every_ppe_cmf_hook_routes_to_the_message_payload() {
440 for hook in [
441 "cmf.tool_pre_invoke",
442 "cmf.tool_post_invoke",
443 "cmf.llm_input",
444 "cmf.llm_output",
445 "cmf.prompt_pre_invoke",
446 "cmf.prompt_post_invoke",
447 "cmf.resource_pre_fetch",
448 "cmf.resource_post_fetch",
449 ] {
450 assert_eq!(
451 payload_kind_for_hook(hook),
452 PayloadKind::CmfMessage,
453 "hook '{hook}' resolved wrongly"
454 );
455 }
456 }
457
458 #[test]
459 fn the_table_covers_every_cmf_hook_ppe_dispatches() {
460 for hook in ppe::praxis_policy_core::hooks::builtin_hook_types() {
463 let name = hook.as_str();
464 if name.starts_with("cmf.") {
465 assert_eq!(
466 payload_kind_for_hook(name),
467 PayloadKind::CmfMessage,
468 "PPE dispatches '{name}' but the hook table does not route it"
469 );
470 }
471 }
472 }
473
474 #[test]
475 fn identity_and_delegation_route_under_ppe_names() {
476 assert_eq!(
477 payload_kind_for_hook("identity.resolve"),
478 PayloadKind::IdentityResolve
479 );
480 assert_eq!(
481 payload_kind_for_hook("token.delegate"),
482 PayloadKind::TokenDelegate
483 );
484 }
485
486 #[test]
487 fn host_registered_legacy_hooks_keep_their_typed_payloads() {
488 let expected = [
489 ("tool_pre_invoke", PayloadKind::ToolPreInvoke),
490 ("tool_post_invoke", PayloadKind::ToolPostInvoke),
491 ("prompt_pre_fetch", PayloadKind::PromptPreFetch),
492 ("prompt_post_fetch", PayloadKind::PromptPostFetch),
493 ("resource_pre_fetch", PayloadKind::ResourcePreFetch),
494 ("resource_post_fetch", PayloadKind::ResourcePostFetch),
495 ];
496
497 for (hook, kind) in expected {
498 assert_eq!(
499 payload_kind_for_hook(hook),
500 kind,
501 "hook '{hook}' resolved wrongly"
502 );
503 }
504
505 let kinds: std::collections::HashSet<_> =
508 expected.iter().map(|(_, k)| format!("{k:?}")).collect();
509 assert_eq!(
510 kinds.len(),
511 6,
512 "each legacy hook needs its own payload kind"
513 );
514 }
515
516 #[test]
517 fn a_hook_outside_the_table_falls_back_to_the_generic_payload() {
518 for hook in [
519 "some_custom_hook",
520 "",
521 "cmf.some_future_hook",
523 "http.request",
525 "elicit",
526 "identity_resolve",
529 "token_delegate",
530 "cmf.prompt_pre_fetch",
532 ] {
533 assert_eq!(
534 payload_kind_for_hook(hook),
535 PayloadKind::Generic,
536 "'{hook}' must not get a typed payload"
537 );
538 assert_eq!(
539 worker_hook_name(hook),
540 hook,
541 "'{hook}' must reach the worker unchanged"
542 );
543 }
544 }
545
546 #[test]
547 fn only_identity_and_delegation_are_credential_hooks() {
548 assert!(is_credential_hook("identity.resolve"));
549 assert!(is_credential_hook("token.delegate"));
550
551 for hook in [
552 "identity_resolve",
553 "token_delegate",
554 "tool_pre_invoke",
555 "tool_post_invoke",
556 "prompt_pre_fetch",
557 "resource_pre_fetch",
558 "cmf.tool_pre_invoke",
559 "cmf.prompt_pre_invoke",
560 "http.request",
561 "unknown",
562 ] {
563 assert!(
564 !is_credential_hook(hook),
565 "'{hook}' must not receive credentials"
566 );
567 }
568 }
569
570 #[test]
571 fn ppe_names_translate_to_the_worker_spelling() {
572 let expected = [
573 ("identity.resolve", "identity_resolve"),
574 ("token.delegate", "token_delegate"),
575 ("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"),
576 ("cmf.prompt_post_invoke", "cmf.prompt_post_fetch"),
577 ("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"),
578 ("cmf.tool_post_invoke", "cmf.tool_post_invoke"),
579 ("cmf.llm_input", "cmf.llm_input"),
580 ("cmf.llm_output", "cmf.llm_output"),
581 ("cmf.resource_pre_fetch", "cmf.resource_pre_fetch"),
582 ("cmf.resource_post_fetch", "cmf.resource_post_fetch"),
583 ("tool_pre_invoke", "tool_pre_invoke"),
584 ];
585 for (dispatched, worker) in expected {
586 assert_eq!(worker_hook_name(dispatched), worker, "{dispatched}");
587 }
588 }
589
590 #[test]
591 fn identity_and_delegation_modified_payloads_keep_their_types() {
592 let boxed = deserialize_payload(
593 "identity.resolve",
594 serde_json::json!({ "raw_token": "**********", "source": "bearer" }),
595 )
596 .expect("parses");
597 assert!(
598 boxed
599 .as_any()
600 .downcast_ref::<IdentityResolvePayload>()
601 .is_some(),
602 "identity.resolve must come back as the identity payload"
603 );
604
605 let boxed = deserialize_payload(
606 "token.delegate",
607 serde_json::json!({ "target_name": "billing-api" }),
608 )
609 .expect("parses");
610 assert!(
611 boxed
612 .as_any()
613 .downcast_ref::<TokenDelegatePayload>()
614 .is_some(),
615 "token.delegate must come back as the delegation payload"
616 );
617 }
618
619 #[test]
622 fn a_tool_pre_invoke_payload_serializes_to_the_worker_field_shape() {
623 let payload = ToolPreInvokePayload {
624 name: "search".into(),
625 args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
626 headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
627 };
628
629 let json = serialize_payload(&payload).expect("serializes");
630 assert_eq!(json["name"], "search");
631 assert_eq!(json["args"]["q"], "rust");
632 assert_eq!(json["headers"]["X-Tenant"], "acme");
633 }
634
635 #[test]
636 fn the_generic_payload_serializes_to_its_inner_value() {
637 let payload = GenericPayload {
639 value: serde_json::json!({"anything": [1, 2, 3]}),
640 };
641 let json = serialize_payload(&payload).expect("serializes");
642 assert_eq!(json["anything"][2], 3);
643 assert!(
644 json.get("value").is_none(),
645 "the wrapper must not appear on the wire"
646 );
647 }
648
649 #[test]
650 fn a_cmf_message_payload_serializes_as_itself() {
651 use ppe::praxis_policy_core::cmf::{Message, Role};
652
653 let payload = MessagePayload {
654 message: Message::text(Role::User, "what is the weather?"),
655 };
656 let json = serialize_payload(&payload).expect("serializes");
657 assert_eq!(json["message"]["role"], "user");
658 }
659
660 #[test]
661 fn an_unforwardable_payload_type_errors_rather_than_sending_null() {
662 #[derive(Debug, Clone)]
663 struct ForeignPayload;
664 ppe::praxis_policy_core::impl_plugin_payload!(ForeignPayload);
665
666 let err =
667 serialize_payload(&ForeignPayload).expect_err("an unknown type cannot be forwarded");
668 assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
669 }
670
671 #[test]
674 fn a_modified_payload_deserializes_into_the_hooks_typed_payload() {
675 let boxed =
676 deserialize_payload("tool_pre_invoke", serde_json::json!({ "name": "redacted" }))
677 .expect("parses");
678 let typed = boxed
679 .as_any()
680 .downcast_ref::<ToolPreInvokePayload>()
681 .expect("must come back as the tool payload, or the executor's downcast fails");
682 assert_eq!(typed.name, "redacted");
683 }
684
685 #[test]
686 fn a_cmf_modified_payload_deserializes_as_a_message_payload() {
687 use ppe::praxis_policy_core::cmf::{Message, Role};
688
689 let original = MessagePayload {
692 message: Message::text(Role::Assistant, "redacted"),
693 };
694 let json = serde_json::to_value(&original).unwrap();
695
696 let boxed = deserialize_payload("cmf.tool_pre_invoke", json).expect("parses");
697 let typed = boxed
698 .as_any()
699 .downcast_ref::<MessagePayload>()
700 .expect("a cmf hook's modified payload must come back as a MessagePayload");
701 assert_eq!(typed.message.role, Role::Assistant);
702 }
703
704 #[test]
705 fn an_unknown_hooks_modified_payload_becomes_a_generic_payload() {
706 let boxed =
707 deserialize_payload("mystery_hook", serde_json::json!({"x": 1})).expect("parses");
708 let generic = boxed
709 .as_any()
710 .downcast_ref::<GenericPayload>()
711 .expect("generic");
712 assert_eq!(generic.value["x"], 1);
713 }
714
715 #[test]
716 fn a_mismatched_modified_payload_errors_and_names_the_hook() {
717 let err = deserialize_payload("tool_pre_invoke", serde_json::json!({ "unexpected": true }))
720 .expect_err("a payload missing required fields must be rejected");
721 assert!(err.to_string().contains("tool_pre_invoke"), "{err}");
722 }
723
724 #[test]
727 fn an_allow_response_becomes_an_allow_result() {
728 let fields = response_to_result(
729 "tool_pre_invoke",
730 serde_json::json!({ "continue_processing": true }),
731 &no_inbound(),
732 )
733 .expect("converts");
734
735 assert!(fields.continue_processing);
736 assert!(fields.violation.is_none());
737 assert!(fields.modified_payload.is_none());
738 assert!(fields.modified_extensions.is_none());
739 }
740
741 #[test]
742 fn a_response_without_continue_processing_defaults_to_allow() {
743 let fields = response_to_result("tool_pre_invoke", serde_json::json!({}), &no_inbound())
746 .expect("converts");
747 assert!(fields.continue_processing);
748 }
749
750 #[test]
751 fn a_deny_response_carries_its_violation() {
752 let fields = response_to_result(
753 "tool_pre_invoke",
754 serde_json::json!({
755 "continue_processing": false,
756 "violation": {
757 "code": "PII_DETECTED",
758 "reason": "email address in args",
759 "description": "matched the email pattern",
760 "details": {"field": "q"},
761 "mcp_error_code": -32603
762 }
763 }),
764 &no_inbound(),
765 )
766 .expect("converts");
767
768 assert!(!fields.continue_processing);
769 let violation = fields.violation.expect("a deny must carry its violation");
770 assert_eq!(violation.code, "PII_DETECTED");
771 assert_eq!(violation.reason, "email address in args");
772 assert_eq!(
773 violation.description.as_deref(),
774 Some("matched the email pattern")
775 );
776 assert_eq!(violation.details["field"], "q");
777 assert_eq!(
778 violation.proto_error_code,
779 Some(-32603),
780 "the Python mcp_error_code maps onto the Rust proto_error_code"
781 );
782 }
783
784 #[test]
785 fn a_violation_with_only_a_reason_still_denies() {
786 let fields = response_to_result(
790 "tool_pre_invoke",
791 serde_json::json!({ "continue_processing": false, "violation": { "reason": "nope" } }),
792 &no_inbound(),
793 )
794 .expect("a sparse violation is still a violation");
795
796 assert!(!fields.continue_processing);
797 let violation = fields.violation.unwrap();
798 assert_eq!(violation.reason, "nope");
799 assert_eq!(violation.code, "");
800 }
801
802 #[test]
803 fn a_modified_payload_survives_the_round_trip() {
804 let fields = response_to_result(
805 "tool_pre_invoke",
806 serde_json::json!({
807 "continue_processing": true,
808 "modified_payload": { "name": "search", "args": {"q": "[REDACTED]"} }
809 }),
810 &no_inbound(),
811 )
812 .expect("converts");
813
814 let payload = fields
815 .modified_payload
816 .expect("the modification must survive");
817 let typed = payload
818 .as_any()
819 .downcast_ref::<ToolPreInvokePayload>()
820 .unwrap();
821 assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
822 }
823
824 #[test]
825 fn modified_extensions_survive_the_round_trip() {
826 let fields = response_to_result(
827 "tool_pre_invoke",
828 serde_json::json!({
829 "continue_processing": true,
830 "modified_extensions": { "custom": { "seen_by": "py-plugin" } }
831 }),
832 &no_inbound(),
833 )
834 .expect("converts");
835
836 let extensions = fields.modified_extensions.expect("extensions must survive");
837 assert_eq!(
838 extensions.custom.expect("custom slot")["seen_by"],
839 "py-plugin"
840 );
841 }
842
843 #[test]
844 fn a_gated_slot_write_without_the_capability_is_dropped() {
845 for slot in ["http", "security", "delegation"] {
856 let fields = response_to_result(
857 "tool_pre_invoke",
858 serde_json::json!({
859 "continue_processing": true,
860 "modified_extensions": { slot: {"labels": ["PII"]} }
861 }),
862 &no_inbound(),
863 )
864 .expect("an unauthorized gated write is dropped, not a protocol error");
865
866 assert!(
867 fields.modified_extensions.is_none(),
868 "the '{slot}' write had no token behind it, so nothing should merge"
869 );
870 }
871 }
872
873 #[test]
874 fn an_extensions_object_with_only_gated_nulls_is_no_modification() {
875 let fields = response_to_result(
879 "tool_pre_invoke",
880 serde_json::json!({
881 "continue_processing": true,
882 "modified_extensions": { "http": null, "security": null, "custom": null }
883 }),
884 &no_inbound(),
885 )
886 .expect("all-null slots are not a write");
887 assert!(fields.modified_extensions.is_none());
888 }
889
890 #[test]
891 fn explicit_nulls_are_treated_as_absent() {
892 let fields = response_to_result(
896 "tool_pre_invoke",
897 serde_json::json!({
898 "continue_processing": true,
899 "modified_payload": null,
900 "modified_extensions": null,
901 "violation": null
902 }),
903 &no_inbound(),
904 )
905 .expect("nulls are absent, not malformed");
906
907 assert!(fields.modified_payload.is_none());
908 assert!(fields.modified_extensions.is_none());
909 assert!(fields.violation.is_none());
910 }
911
912 #[test]
913 fn a_malformed_modified_payload_surfaces_as_a_protocol_error() {
914 let Err(err) = response_to_result(
915 "tool_pre_invoke",
916 serde_json::json!({ "continue_processing": true, "modified_payload": "not an object" }),
917 &no_inbound(),
918 ) else {
919 panic!("a payload that cannot be rebuilt must not be silently dropped");
920 };
921 assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
922 }
923}