Skip to main content

cpex_hosts_python/
conversion.rs

1// Location: ./crates/cpex-hosts-python/src/conversion.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Payload serialization, hook-name resolution, and the response-to-result
7// conversion.
8//
9// Two directions, both keyed on the hook name:
10//
11//   outbound: `&dyn PluginPayload` ──> JSON the worker's `json_to_payload`
12//             reconstructs into the matching Pydantic model
13//   inbound:  the worker's response JSON ──> `ErasedResultFields` the
14//             executor consumes
15//
16// # Why a registry
17//
18// The host forwards arbitrary payloads, so nothing here can be generic over a
19// single `P: PluginPayload`. Outbound, serialization is a downcast chain
20// (mirroring `cpex-ffi`'s ordering) because the concrete type arrives erased.
21// Inbound, the hook *name* selects which typed payload a `modified_payload`
22// deserializes into: a `PayloadKind` per name in the hook table, and the
23// generic payload for any name outside it. The same table translates PPE's
24// dispatched names to the spellings the Python worker registers.
25
26use 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/// Wraps any JSON value for hooks with no typed payload.
48///
49/// The third copy of this type in the workspace — `cpex-ffi` and the Python
50/// bindings each define their own, because praxis-policy-core exports the
51/// `impl_plugin_payload!` macro but not a shared struct. Consolidating all
52/// three into praxis-policy-core is deferred follow-up work; it would touch the FFI and
53/// the bindings, and is not needed to ship this host.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GenericPayload {
56    pub value: Value,
57}
58
59ppe::praxis_policy_core::impl_plugin_payload!(GenericPayload);
60
61/// Which payload type a hook name maps to.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PayloadKind {
64    /// A `cmf.*` hook PPE dispatches: the CMF message payload.
65    CmfMessage,
66    ToolPreInvoke,
67    ToolPostInvoke,
68    PromptPreFetch,
69    PromptPostFetch,
70    ResourcePreFetch,
71    ResourcePostFetch,
72    /// `identity.resolve`: carries the inbound credential.
73    IdentityResolve,
74    /// `token.delegate`: carries the delegated token.
75    TokenDelegate,
76    /// No typed payload for this name: carry the JSON as-is.
77    Generic,
78}
79
80/// One row of the hook table: the name the engine dispatches, the payload it
81/// carries, and the name the Python worker knows it by.
82struct HookRoute {
83    dispatched: &'static str,
84    kind: PayloadKind,
85    worker: &'static str,
86}
87
88/// Every hook name this host gives a typed payload, keyed on the name the
89/// engine dispatches.
90///
91/// The CMF rows and the identity and delegation rows are PPE's dispatched
92/// names. The Python framework still keys its payload registry, its hook
93/// methods and its credential handling on the older spellings, so the
94/// `worker` column translates on the wire: `identity.resolve` reaches the
95/// worker as `identity_resolve`, `token.delegate` as `token_delegate`, and the
96/// CMF prompt pair as `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch`.
97///
98/// The CMF rows describe a worker that claims the `cmf_hooks` handshake
99/// feature. Any other worker gets the tool, prompt and resource CMF hooks
100/// through `crate::legacy::cmf_shim` instead, which picks the legacy payload
101/// and hook name itself and refuses the LLM hooks.
102///
103/// The six unprefixed tool, prompt and resource rows are not PPE hooks. PPE's
104/// config loader rejects them unless a host registers hook metadata for them
105/// and then invokes them with the matching typed payload, so they stay for
106/// such hosts. The unprefixed `identity_resolve` and `token_delegate` are
107/// deliberately absent: raw credentials travel only on PPE's names.
108const 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
203/// Resolve a hook name to its payload kind.
204///
205/// Exact match against the hook table. A name the table does not hold,
206/// including a custom `cmf.`-prefixed one, gets the generic payload.
207pub fn payload_kind_for_hook(hook_name: &str) -> PayloadKind {
208    lookup_route(hook_name).map_or(PayloadKind::Generic, |r| r.kind)
209}
210
211/// The hook name the Python worker knows `hook_name` by.
212///
213/// Used for the task's `hook_type` and the plugin config's `hooks` list, so the
214/// worker finds the right payload model, hook method and credential slot. A
215/// name outside the hook table passes through unchanged.
216pub fn worker_hook_name(hook_name: &str) -> &str {
217    lookup_route(hook_name).map_or(hook_name, |r| r.worker)
218}
219
220/// Whether a hook carries raw credentials.
221///
222/// Only `identity.resolve` and `token.delegate` do: they are the only two
223/// hooks whose Python payload models a raw token at all
224/// (`IdentityPayload.raw_token`, `DelegationPayload.bearer_token`). The Python
225/// `Extensions` model has no raw-credential slot, so no other hook has
226/// anywhere to receive one.
227pub 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
234/// Serialize an erased payload to the JSON shape the worker reconstructs.
235///
236/// The downcast chain covers every type this host can hand out, in rough
237/// frequency order. `MessagePayload` comes first, mirroring `cpex-ffi`'s
238/// `serialize_payload`.
239///
240/// An unrecognized concrete type is an error rather than a silent `null`: it
241/// means some other host handed this adapter a payload it cannot forward, and
242/// sending `null` would surface inside the worker as a confusing Pydantic
243/// validation failure instead.
244pub fn serialize_payload(payload: &dyn PluginPayload) -> Result<Value, HostError> {
245    let any = payload.as_any();
246
247    // Macro rather than a chain of near-identical `if let` blocks — ten arms
248    // written out drift, and the pattern is mechanical.
249    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    // PPE's own identity and delegation payloads, which its FFI, bindings and
265    // APL delegate step dispatch on these two hooks. Translated rather than
266    // serialized as is: the worker rebuilds its own Python models, and no raw
267    // token may ride this JSON (see `credential_payloads`).
268    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    // The generic payload unwraps to its inner value rather than serializing
288    // the wrapper, so the worker sees the payload itself.
289    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
301/// Rebuild a typed payload from JSON, per the hook's payload kind.
302///
303/// Used for a `modified_payload` coming back from the worker: it must be
304/// re-erased as the *same* concrete type the executor expects for the hook, or
305/// a downstream downcast fails and the modification is dropped.
306pub 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
337/// Convert a worker response into the erased result the executor consumes.
338///
339/// Preserves all four decision-bearing fields: whether processing continues,
340/// any violation, a modified payload, and modified extensions. A response
341/// missing `continue_processing` defaults to `true`, matching
342/// `PluginResult`'s Pydantic default — a plugin that returns nothing means
343/// "allow", not "deny".
344///
345/// `inbound` is the capability-filtered view this plugin was dispatched with.
346/// The returned-extensions path needs it to reuse the original `Arc`s for
347/// immutable slots and to read the write tokens the executor issued — see the
348/// `extensions` module for why both are load-bearing.
349pub 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
382/// Parse a violation, tolerating the Python model's extra fields.
383///
384/// The Python `PluginViolation` carries `mcp_error_code` and
385/// `http_status_code`, which the Rust type does not model — so a strict
386/// `from_value` into the Rust struct is not used. `reason` and `code` are
387/// pulled out explicitly and defaulted, because a deny with an empty reason is
388/// still a deny and must not be downgraded into an error.
389fn 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    // The Python side names this `mcp_error_code`; the Rust side calls the same
411    // concept `proto_error_code`.
412    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    /// Inbound view for the response-conversion tests.
426    ///
427    /// Empty by default: these tests exercise response parsing, not the
428    /// capability-filtered delivery path, and an empty view carries no write
429    /// tokens — so a gated slot in a response is dropped rather than applied.
430    /// `extensions::tests` and `tests/extensions_merge_e2e.rs` cover the
431    /// token-bearing cases.
432    fn no_inbound() -> ppe::praxis_policy_core::extensions::Extensions {
433        ppe::praxis_policy_core::extensions::Extensions::default()
434    }
435
436    // --- hook name resolution -----------------------------------------------
437
438    #[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        // A CMF hook added upstream would otherwise fall to the generic payload
461        // and lose its typed modified payload without any error.
462        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        // All six are distinct: a copy-paste in the table would show up here
506        // rather than as a wrong payload at runtime.
507        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            // A custom name with the CMF prefix is not a CMF hook PPE dispatches.
522            "cmf.some_future_hook",
523            // PPE's own HTTP and elicitation hooks have no Python payload model.
524            "http.request",
525            "elicit",
526            // The unprefixed identity and delegation names are gone: credentials
527            // travel only on PPE's names.
528            "identity_resolve",
529            "token_delegate",
530            // The old CMF prompt spelling is the worker's, not a dispatched name.
531            "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    // --- outbound serialization ---------------------------------------------
620
621    #[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        // Not to `{"value": ...}` — the worker expects the payload itself.
638        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    // --- inbound deserialization -------------------------------------------
672
673    #[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        // Round-tripped through the real type so the JSON matches the CMF
690        // schema rather than a hand-guessed shape.
691        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        // `name` is required on the tool payload; omitting it must not silently
718        // yield a default-constructed payload.
719        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    // --- response to result --------------------------------------------------
725
726    #[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        // Matches PluginResult's Pydantic default. Defaulting to deny would
744        // block traffic on any plugin that returns a bare result.
745        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        // Tolerance matters here: a partially-populated violation must not be
787        // converted into a host error, which would turn a deny into a
788        // policy-dependent failure.
789        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        // `http`, `security`, and `delegation` writes are gated by a WriteToken
846        // the *executor* mints from the plugin's declared capabilities and
847        // carries on the inbound view. An empty inbound view means no token was
848        // issued, so the write is dropped rather than applied — the host cannot
849        // mint a token and must not honor an unauthorized write.
850        //
851        // This used to be a hard error, back when the host had no inbound view
852        // to consult and so could not tell an authorized write from an
853        // unauthorized one. It can now, so the tier is enforced instead of
854        // refused. `extensions::tests` covers the drop at slot granularity.
855        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        // Pydantic emits unset Optionals as null, so a plugin that touched
876        // nothing still sends every key. That must not read as an attempted
877        // gated write.
878        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        // Pydantic serializes unset Optionals as null, so null and absent must
893        // behave identically or every allow response would try to parse a
894        // null payload.
895        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}