Skip to main content

cpex_hosts_python/legacy/
cmf_shim.rs

1// Location: ./crates/cpex-hosts-python/src/legacy/cmf_shim.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Payload shim between PPE's `cmf.*` hooks and the legacy typed hooks.
7//
8// # Why this exists
9//
10// PPE dispatches tool, prompt and resource traffic only on `cmf.*` hooks, with
11// a CMF `MessagePayload`. Python workers that predate `cmf.*` hook support
12// (the released 0.1.x SDK) register no `cmf.*` hook in the worker, and their
13// plugins implement `tool_pre_invoke` and friends against the typed payloads
14// (`ToolPreInvokePayload` and so on). Without this module such a plugin cannot
15// run on PPE at all.
16//
17// So, for a worker that does not claim the `cmf_hooks` handshake feature, the
18// adapter routes each of the six tool, prompt and resource `cmf.*` hooks
19// through here:
20//
21//   outbound: the CMF message ──> the legacy typed payload + legacy hook name
22//   inbound:  a modified legacy payload ──> the same CMF message with only the
23//             fields the legacy payload carries replaced
24//
25// Allow, deny and violations pass through untouched; only `modified_payload`
26// is translated. A message the legacy payload cannot represent fails closed
27// with `HostError::LegacyShim`, never a silent allow.
28//
29// # Field decisions
30//
31// Every field of the CMF parts the shim reads. "sent" fields reach the plugin
32// and are mapped back. "kept" fields are not sent and survive unchanged, which
33// is safe because they neither qualify what is authorized nor carry content a
34// plugin could need to rewrite. "refuse" fields are security-relevant and have
35// no legacy slot, so a message that sets them fails closed.
36//
37//   part              field                decision
38//   ----------------  -------------------  -------------------------------------
39//   tool_call         tool_call_id         kept: correlation only
40//                     name, arguments      sent
41//                     namespace            refuse when non-empty: a plugin that
42//                                          authorizes by bare name would approve
43//                                          the same name in any namespace
44//   tool_result       tool_call_id         kept: correlation only
45//                     tool_name, content   sent (structured content travels
46//                                          inside `content` as is)
47//                     is_error             refuse when true unless `content`
48//                                          carries `"isError": true`; a modified
49//                                          result must keep it, and one that
50//                                          sets it sets the flag
51//   prompt_request    prompt_request_id    kept: correlation only
52//                     name, arguments      sent
53//                     server_id            refuse when non-empty: same reason as
54//                                          tool namespace
55//   prompt_result     prompt_request_id    kept: correlation only
56//                     prompt_name          sent
57//                     messages             sent (role and text; per-message
58//                                          `schema_version` and `channel` kept)
59//                     content              refuse when non-empty: rendered text
60//                                          the plugin cannot see or redact
61//                     is_error             refuse when true: no legacy slot
62//                     error_message        refuse when non-empty: same as content
63//   resource          resource_request_id  sent on post-fetch as `id`, else kept
64//                     uri                  sent
65//                     name, description    kept: descriptive labels
66//                     resource_type        kept: the URI is the address
67//                     annotations          kept: labels that only restrict
68//                                          downstream, never widen
69//                     version              refuse when non-empty: selects which
70//                                          revision the URI resolves to
71//                     content              sent on post-fetch as `text`; on
72//                                          pre-fetch a URI rewrite refuses if set
73//                     blob                 refuse when set
74//                     mime_type            sent on post-fetch; on pre-fetch a
75//                                          URI rewrite refuses if set
76//                     size_bytes           not sent; recomputed when post-fetch
77//                                          text changes; on pre-fetch a URI
78//                                          rewrite refuses if set
79//   resource_ref      resource_request_id  kept: correlation only
80//                     uri                  sent
81//                     name, resource_type  kept: descriptive
82//                     range_start/end,     kept: they narrow what is read within
83//                     selector             the URI the plugin approved
84//   any other part    (the whole part)     post hooks: refuse unless it is a
85//                                          text or thinking part with no text.
86//                                          The plugin never sees it, so a PII
87//                                          filter would pass result content
88//                                          through unredacted.
89//                                          pre hooks: kept. The plugin
90//                                          authorizes the call, request or URI
91//                                          it sees, and narration beside it
92//                                          goes back unchanged. A worker that
93//                                          claims `cmf_hooks` receives the
94//                                          whole message instead
95//
96// # Removing it
97//
98// Once every supported worker claims `cmf_hooks`, delete this file, its
99// `pub mod` line in `legacy/mod.rs`, and the `shim` branch in
100// `PythonHookAdapter::invoke` and `build_task`. Nothing else depends on it.
101
102use std::collections::HashMap;
103
104use ppe::praxis_policy_core::cmf::constants::{
105    HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
106    HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
107    HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
108};
109use ppe::praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Resource, Role};
110use ppe::praxis_policy_core::hooks::payload::PluginPayload;
111use serde::Serialize;
112use serde::de::DeserializeOwned;
113use serde_json::Value;
114
115use super::payloads::{
116    PromptPostFetchPayload, PromptPreFetchPayload, ResourcePostFetchPayload,
117    ResourcePreFetchPayload, ToolPostInvokePayload, ToolPreInvokePayload,
118};
119use crate::error::HostError;
120
121/// The six `cmf.*` hooks the shim translates, and the legacy hook each one
122/// reaches the worker as.
123const SHIMMED_HOOKS: &[(&str, Shim)] = &[
124    (HOOK_CMF_TOOL_PRE_INVOKE, Shim::ToolPreInvoke),
125    (HOOK_CMF_TOOL_POST_INVOKE, Shim::ToolPostInvoke),
126    (HOOK_CMF_PROMPT_PRE_INVOKE, Shim::PromptPreFetch),
127    (HOOK_CMF_PROMPT_POST_INVOKE, Shim::PromptPostFetch),
128    (HOOK_CMF_RESOURCE_PRE_FETCH, Shim::ResourcePreFetch),
129    (HOOK_CMF_RESOURCE_POST_FETCH, Shim::ResourcePostFetch),
130];
131
132/// Which legacy hook a shimmed `cmf.*` hook maps to.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum Shim {
135    ToolPreInvoke,
136    ToolPostInvoke,
137    PromptPreFetch,
138    PromptPostFetch,
139    ResourcePreFetch,
140    ResourcePostFetch,
141}
142
143impl Shim {
144    fn for_hook(hook_name: &str) -> Option<Self> {
145        SHIMMED_HOOKS
146            .iter()
147            .find(|(name, _)| *name == hook_name)
148            .map(|(_, shim)| *shim)
149    }
150
151    /// The legacy hook name: the `hook_type` the worker dispatches on and the
152    /// method name a 0.1.x plugin implements.
153    fn legacy_hook(self) -> &'static str {
154        match self {
155            Self::ToolPreInvoke => "tool_pre_invoke",
156            Self::ToolPostInvoke => "tool_post_invoke",
157            Self::PromptPreFetch => "prompt_pre_fetch",
158            Self::PromptPostFetch => "prompt_post_fetch",
159            Self::ResourcePreFetch => "resource_pre_fetch",
160            Self::ResourcePostFetch => "resource_post_fetch",
161        }
162    }
163
164    /// Whether this is a post hook, whose result content a plugin may need to
165    /// inspect or redact in full.
166    fn is_post(self) -> bool {
167        matches!(
168            self,
169            Self::ToolPostInvoke | Self::PromptPostFetch | Self::ResourcePostFetch
170        )
171    }
172
173    /// The CMF content part the legacy payload is built from, for diagnostics.
174    fn part_name(self) -> &'static str {
175        match self {
176            Self::ToolPreInvoke => "tool_call",
177            Self::ToolPostInvoke => "tool_result",
178            Self::PromptPreFetch => "prompt_request",
179            Self::PromptPostFetch => "prompt_result",
180            Self::ResourcePreFetch => "resource or resource_ref",
181            Self::ResourcePostFetch => "resource",
182        }
183    }
184
185    /// Whether `part` is the kind this hook's legacy payload is built from.
186    fn matches(self, part: &ContentPart) -> bool {
187        match self {
188            Self::ToolPreInvoke => matches!(part, ContentPart::ToolCall { .. }),
189            Self::ToolPostInvoke => matches!(part, ContentPart::ToolResult { .. }),
190            Self::PromptPreFetch => matches!(part, ContentPart::PromptRequest { .. }),
191            Self::PromptPostFetch => matches!(part, ContentPart::PromptResult { .. }),
192            // A pre-fetch only needs the URI, which both resource parts carry.
193            // A post-fetch needs the content, which only `resource` carries; a
194            // `resource_ref` there is counted so it fails with a clear reason
195            // rather than as "no resource part".
196            Self::ResourcePreFetch | Self::ResourcePostFetch => matches!(
197                part,
198                ContentPart::Resource { .. } | ContentPart::ResourceRef { .. }
199            ),
200        }
201    }
202}
203
204/// Whether `hook_name` is a `cmf.*` hook PPE dispatches.
205///
206/// Every one of these needs either a worker that claims `cmf_hooks` or the
207/// shim. The two LLM hooks are included: they have no legacy equivalent, so a
208/// worker without `cmf_hooks` cannot serve them and [`to_legacy`] refuses.
209pub fn is_cmf_hook(hook_name: &str) -> bool {
210    Shim::for_hook(hook_name).is_some()
211        || hook_name == HOOK_CMF_LLM_INPUT
212        || hook_name == HOOK_CMF_LLM_OUTPUT
213}
214
215/// The legacy hook name a shimmed `cmf.*` hook reaches the worker as, or
216/// `None` for any other name.
217pub fn legacy_hook_name(hook_name: &str) -> Option<&'static str> {
218    Shim::for_hook(hook_name).map(Shim::legacy_hook)
219}
220
221/// One translated invocation: the legacy payload to send, plus what is needed
222/// to map a modified legacy payload back onto the original message.
223#[derive(Debug)]
224pub struct ShimmedPayload {
225    shim: Shim,
226    hook_name: String,
227    /// The message as dispatched. A modified payload is applied to a clone of
228    /// it, so every part and field the legacy payload does not carry survives.
229    original: MessagePayload,
230    /// Index of the content part the legacy payload was built from.
231    part: usize,
232    json: Value,
233}
234
235impl ShimmedPayload {
236    /// The legacy hook name to send as the task's `hook_type`.
237    pub fn legacy_hook(&self) -> &'static str {
238        self.shim.legacy_hook()
239    }
240
241    /// The legacy payload JSON, in the shape the worker's `json_to_payload`
242    /// rebuilds into the matching Pydantic model.
243    pub fn payload(&self) -> &Value {
244        &self.json
245    }
246
247    /// Map a worker's modified legacy payload back onto the original message.
248    ///
249    /// Only the fields the legacy payload carries are written. A modification
250    /// the message has nowhere to hold (tool headers, resource metadata, a
251    /// prompt result description, a resource blob) fails closed: dropping it
252    /// would silently undo what the plugin asked for.
253    pub fn apply_modified(&self, modified: Value) -> Result<MessagePayload, HostError> {
254        let mut payload = self.original.clone();
255        let part = payload
256            .message
257            .content
258            .get_mut(self.part)
259            .ok_or_else(|| self.error("the original content part is gone"))?;
260
261        match (self.shim, part) {
262            (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => {
263                let legacy: ToolPreInvokePayload = self.parse(modified)?;
264                if legacy.headers.as_ref().is_some_and(|h| !h.is_empty()) {
265                    return Err(self.error(
266                        "the plugin returned `headers`, which a CMF tool call cannot carry",
267                    ));
268                }
269                content.name = legacy.name;
270                content.arguments = legacy.args.unwrap_or_default();
271            },
272            (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => {
273                let legacy: ToolPostInvokePayload = self.parse(modified)?;
274                if content.is_error && !result_says_error(&legacy.result) {
275                    return Err(self.error(
276                        "the plugin cleared `isError` on an error tool result, and the CMF \
277                         `is_error` flag is not mapped back",
278                    ));
279                }
280                // A plugin may turn a success into an error; the CMF flag
281                // follows, so downstream reads the error the plugin set.
282                content.is_error = content.is_error || result_says_error(&legacy.result);
283                content.tool_name = legacy.name;
284                content.content = legacy.result;
285            },
286            (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => {
287                let legacy: PromptPreFetchPayload = self.parse(modified)?;
288                content.name = legacy.prompt_id;
289                content.arguments = legacy
290                    .args
291                    .unwrap_or_default()
292                    .into_iter()
293                    .map(|(k, v)| (k, Value::String(v)))
294                    .collect();
295            },
296            (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => {
297                let legacy: PromptPostFetchPayload = self.parse(modified)?;
298                content.messages = self.messages_from_legacy(legacy.result, &content.messages)?;
299                content.prompt_name = legacy.prompt_id;
300            },
301            (Shim::ResourcePreFetch, ContentPart::Resource { content }) => {
302                let uri = self.resource_pre_uri(modified)?;
303                // Embedded content, its type and its size describe the
304                // original URI. Carrying them onto a rewritten URI would serve
305                // the old content under the address the plugin chose.
306                if uri != content.uri
307                    && (content.is_embedded()
308                        || content.mime_type.is_some()
309                        || content.size_bytes.is_some())
310                {
311                    return Err(self.error(
312                        "the plugin rewrote the URI of a resource that carries content, \
313                         mime_type or size_bytes from the original URI",
314                    ));
315                }
316                content.uri = uri;
317            },
318            (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => {
319                content.uri = self.resource_pre_uri(modified)?;
320            },
321            (Shim::ResourcePostFetch, ContentPart::Resource { content }) => {
322                let legacy: ResourcePostFetchPayload = self.parse(modified)?;
323                let before = content.content.clone();
324                let original_uri = std::mem::replace(&mut content.uri, legacy.uri);
325                match legacy.content {
326                    Value::Null => content.content = None,
327                    Value::String(text) => content.content = Some(text),
328                    Value::Object(fields) => {
329                        if fields.get("blob").is_some_and(|b| !b.is_null()) {
330                            return Err(self.error(
331                                "the plugin returned resource `content.blob`; binary resource \
332                                 content is not carried by the shim",
333                            ));
334                        }
335                        // `content.id` and `content.uri` have no slot of their
336                        // own: the part keeps one id and one URI. A change to
337                        // either would otherwise be dropped.
338                        if fields.get("id").is_some_and(|id| {
339                            id.as_str() != Some(content.resource_request_id.as_str())
340                        }) {
341                            return Err(self.error(
342                                "the plugin changed `content.id`, which is not mapped back",
343                            ));
344                        }
345                        if fields.get("uri").is_some_and(|u| {
346                            u.as_str() != Some(content.uri.as_str())
347                                && u.as_str() != Some(original_uri.as_str())
348                        }) {
349                            return Err(self.error(
350                                "the plugin changed `content.uri` to a value other than `uri`, \
351                                 and only `uri` is mapped back",
352                            ));
353                        }
354                        content.content = match fields.get("text") {
355                            None | Some(Value::Null) => None,
356                            Some(Value::String(text)) => Some(text.clone()),
357                            Some(_) => {
358                                return Err(self.error("`content.text` must be a string or null"));
359                            },
360                        };
361                        if let Some(mime) = fields.get("mime_type") {
362                            content.mime_type = match mime {
363                                Value::Null => None,
364                                Value::String(m) => Some(m.clone()),
365                                _ => {
366                                    return Err(
367                                        self.error("`content.mime_type` must be a string or null")
368                                    );
369                                },
370                            };
371                        }
372                    },
373                    _ => return Err(self.error("`content` must be an object, a string, or null")),
374                }
375                // A size measured from the original text would misstate the
376                // text the plugin returned.
377                if content.content != before && content.size_bytes.is_some() {
378                    content.size_bytes = content.content.as_ref().map(|t| t.len() as u64);
379                }
380            },
381            _ => return Err(self.error("the original content part changed kind")),
382        }
383
384        Ok(payload)
385    }
386
387    fn resource_pre_uri(&self, modified: Value) -> Result<String, HostError> {
388        let legacy: ResourcePreFetchPayload = self.parse(modified)?;
389        if legacy.metadata.as_ref().is_some_and(|m| !m.is_empty()) {
390            return Err(self.error(
391                "the plugin returned resource `metadata`, which a CMF resource part cannot carry",
392            ));
393        }
394        Ok(legacy.uri)
395    }
396
397    /// Rebuild CMF prompt messages from a legacy `PromptResult` shape.
398    ///
399    /// A message keeps its original `schema_version` and `channel` when one
400    /// exists at the same position; a message the plugin appended gets the
401    /// defaults.
402    fn messages_from_legacy(
403        &self,
404        result: Value,
405        original: &[Message],
406    ) -> Result<Vec<Message>, HostError> {
407        let Value::Object(result) = result else {
408            return Err(self.error("`result` must be an object with a `messages` list"));
409        };
410        if result.get("description").is_some_and(|d| !d.is_null()) {
411            return Err(self.error(
412                "the plugin returned a prompt result `description`, which a CMF prompt result \
413                 cannot carry",
414            ));
415        }
416        let Some(Value::Array(items)) = result.get("messages") else {
417            return Err(self.error("`result.messages` must be a list"));
418        };
419
420        items
421            .iter()
422            .enumerate()
423            .map(|(i, item)| {
424                let role = item
425                    .get("role")
426                    .cloned()
427                    .and_then(|r| serde_json::from_value::<Role>(r).ok())
428                    .ok_or_else(|| {
429                        self.error(&format!("`result.messages[{i}].role` is not a CMF role"))
430                    })?;
431                let text = match item.get("content") {
432                    Some(Value::Object(c))
433                        if c.get("type").and_then(Value::as_str) == Some("text") =>
434                    {
435                        c.get("text").and_then(Value::as_str)
436                    },
437                    _ => None,
438                }
439                .ok_or_else(|| {
440                    self.error(&format!(
441                        "`result.messages[{i}].content` must be text content \
442                         ({{\"type\": \"text\", \"text\": ...}})"
443                    ))
444                })?;
445
446                let mut message = original
447                    .get(i)
448                    .cloned()
449                    .unwrap_or_else(|| Message::text(role, ""));
450                message.role = role;
451                message.content = vec![ContentPart::Text {
452                    text: text.to_string(),
453                }];
454                Ok(message)
455            })
456            .collect()
457    }
458
459    fn parse<T: DeserializeOwned>(&self, modified: Value) -> Result<T, HostError> {
460        serde_json::from_value(modified).map_err(|e| HostError::Protocol {
461            message: format!(
462                "worker returned a modified payload that does not match the legacy hook '{}' \
463                 (dispatched as '{}'): {e}",
464                self.shim.legacy_hook(),
465                self.hook_name
466            ),
467        })
468    }
469
470    fn error(&self, message: &str) -> HostError {
471        shim_error(&self.hook_name, self.shim.legacy_hook(), message)
472    }
473}
474
475fn shim_error(hook_name: &str, legacy: &str, message: &str) -> HostError {
476    HostError::LegacyShim {
477        hook: hook_name.to_string(),
478        message: format!("translating to the legacy '{legacy}' payload: {message}"),
479    }
480}
481
482/// Translate a `cmf.*` hook invocation into the legacy hook a worker without
483/// `cmf_hooks` support understands.
484///
485/// Fails closed for the LLM hooks (no legacy equivalent), for a payload that
486/// is not a `MessagePayload`, and for a message that does not hold exactly one
487/// content part of the kind the hook's legacy payload is built from.
488pub fn to_legacy(
489    hook_name: &str,
490    payload: &dyn PluginPayload,
491) -> Result<ShimmedPayload, HostError> {
492    let Some(shim) = Shim::for_hook(hook_name) else {
493        return Err(HostError::LegacyShim {
494            hook: hook_name.to_string(),
495            message: "it has no legacy hook equivalent, so only a worker that claims the \
496                      `cmf_hooks` feature can run it. Upgrade the cpex package in the plugin's \
497                      venv, or remove the hook from the plugin's `hooks:` list."
498                .into(),
499        });
500    };
501    let legacy = shim.legacy_hook();
502
503    let original = payload
504        .as_any()
505        .downcast_ref::<MessagePayload>()
506        .ok_or_else(|| shim_error(hook_name, legacy, "the payload is not a CMF MessagePayload"))?
507        .clone();
508
509    let candidates: Vec<usize> = original
510        .message
511        .content
512        .iter()
513        .enumerate()
514        .filter(|(_, p)| shim.matches(p))
515        .map(|(i, _)| i)
516        .collect();
517    let part = match candidates.as_slice() {
518        [only] => *only,
519        [] => {
520            return Err(shim_error(
521                hook_name,
522                legacy,
523                &format!("the message has no {} content part", shim.part_name()),
524            ));
525        },
526        many => {
527            return Err(shim_error(
528                hook_name,
529                legacy,
530                &format!(
531                    "the message has {} {} content parts and the legacy payload holds one",
532                    many.len(),
533                    shim.part_name()
534                ),
535            ));
536        },
537    };
538
539    // The legacy payload holds the one part. On a post hook anything else in
540    // the message would go downstream on allow without the plugin having seen
541    // it. On a pre hook the plugin authorizes the part it sees, and siblings
542    // are returned unchanged.
543    if let Some(sibling) = original
544        .message
545        .content
546        .iter()
547        .enumerate()
548        .find(|(i, p)| shim.is_post() && *i != part && carries_content(p))
549        .map(|(_, p)| p)
550    {
551        return Err(shim_error(
552            hook_name,
553            legacy,
554            &format!(
555                "the message holds a {} content part beside the {} part, which the legacy \
556                 payload cannot carry, so a plugin could not inspect or redact it",
557                part_kind(sibling),
558                shim.part_name()
559            ),
560        ));
561    }
562
563    let err = |message: &str| shim_error(hook_name, legacy, message);
564    let json = match (shim, &original.message.content[part]) {
565        (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => {
566            if is_set(&content.namespace) {
567                return Err(err(
568                    "the tool call has a `namespace`, which the legacy payload cannot carry; \
569                     a plugin that authorizes by bare `name` would approve it in any namespace",
570                ));
571            }
572            to_json(
573                &ToolPreInvokePayload {
574                    name: content.name.clone(),
575                    args: Some(content.arguments.clone()),
576                    headers: None,
577                },
578                &err,
579            )?
580        },
581        (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => {
582            if content.is_error && !result_says_error(&content.content) {
583                return Err(err(
584                    "the tool result has `is_error` set, and its `content` does not carry \
585                     `\"isError\": true` for the legacy plugin to see",
586                ));
587            }
588            to_json(
589                &ToolPostInvokePayload {
590                    name: content.tool_name.clone(),
591                    result: content.content.clone(),
592                },
593                &err,
594            )?
595        },
596        (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => {
597            if is_set(&content.server_id) {
598                return Err(err(
599                    "the prompt request has a `server_id`, which the legacy payload cannot \
600                     carry; a plugin that authorizes by bare `prompt_id` would approve it on \
601                     any server",
602                ));
603            }
604            // dict[str, str] on the Python side, which Pydantic will not coerce
605            // a number or an object into.
606            let mut args = HashMap::with_capacity(content.arguments.len());
607            for (key, value) in &content.arguments {
608                let Value::String(s) = value else {
609                    return Err(err(&format!(
610                        "prompt argument '{key}' is not a string, and the legacy payload's \
611                         `args` is dict[str, str]"
612                    )));
613                };
614                args.insert(key.clone(), s.clone());
615            }
616            to_json(
617                &PromptPreFetchPayload {
618                    prompt_id: content.name.clone(),
619                    args: Some(args),
620                },
621                &err,
622            )?
623        },
624        (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => {
625            // The legacy result holds only messages. Text left in these fields
626            // would survive a plugin that redacted the messages.
627            if is_set(&content.content) {
628                return Err(err(
629                    "the prompt result has rendered `content`, which the legacy payload cannot \
630                     carry, so a plugin could not inspect or redact it",
631                ));
632            }
633            if is_set(&content.error_message) {
634                return Err(err(
635                    "the prompt result has an `error_message`, which the legacy payload cannot \
636                     carry, so a plugin could not inspect or redact it",
637                ));
638            }
639            if content.is_error {
640                return Err(err(
641                    "the prompt result has `is_error` set, which the legacy payload cannot carry",
642                ));
643            }
644            let mut messages = Vec::with_capacity(content.messages.len());
645            for (i, message) in content.messages.iter().enumerate() {
646                let [ContentPart::Text { text }] = message.content.as_slice() else {
647                    return Err(err(&format!(
648                        "prompt result message {i} is not a single text part, and the legacy \
649                         prompt message holds one text content"
650                    )));
651                };
652                messages.push(serde_json::json!({
653                    "role": message.role,
654                    "content": { "type": "text", "text": text },
655                }));
656            }
657            to_json(
658                &PromptPostFetchPayload {
659                    prompt_id: content.prompt_name.clone(),
660                    result: serde_json::json!({ "messages": messages, "description": null }),
661                },
662                &err,
663            )?
664        },
665        (Shim::ResourcePreFetch, ContentPart::Resource { content }) => {
666            refuse_resource_version(content, &err)?;
667            to_json(
668                &ResourcePreFetchPayload {
669                    uri: content.uri.clone(),
670                    metadata: None,
671                },
672                &err,
673            )?
674        },
675        (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => to_json(
676            &ResourcePreFetchPayload {
677                uri: content.uri.clone(),
678                metadata: None,
679            },
680            &err,
681        )?,
682        (Shim::ResourcePostFetch, ContentPart::Resource { content }) => {
683            refuse_resource_version(content, &err)?;
684            if content.blob.is_some() {
685                return Err(err(
686                    "the resource carries binary `blob` content, which the shim does not \
687                     translate",
688                ));
689            }
690            // The legacy `ResourceContent` shape 0.1.x plugins read.
691            to_json(
692                &ResourcePostFetchPayload {
693                    uri: content.uri.clone(),
694                    content: serde_json::json!({
695                        "type": "resource",
696                        "id": content.resource_request_id,
697                        "uri": content.uri,
698                        "mime_type": content.mime_type,
699                        "text": content.content,
700                    }),
701                },
702                &err,
703            )?
704        },
705        (Shim::ResourcePostFetch, ContentPart::ResourceRef { .. }) => {
706            return Err(err(
707                "the message holds a resource_ref with no content, and the legacy post-fetch \
708                 payload carries the content",
709            ));
710        },
711        // `candidates` only holds parts `shim.matches` accepted, and every
712        // accepted pairing is handled above.
713        _ => return Err(err("unexpected content part")),
714    };
715
716    Ok(ShimmedPayload {
717        shim,
718        hook_name: hook_name.to_string(),
719        original,
720        part,
721        json,
722    })
723}
724
725/// Whether an optional string field holds a value. An empty string carries
726/// nothing and counts as unset.
727fn is_set(field: &Option<String>) -> bool {
728    field.as_deref().is_some_and(|s| !s.is_empty())
729}
730
731/// Whether a part the legacy payload does not carry holds anything a plugin
732/// would need to see. Only a text or thinking part with no text holds nothing.
733fn carries_content(part: &ContentPart) -> bool {
734    match part {
735        ContentPart::Text { text } | ContentPart::Thinking { text } => !text.trim().is_empty(),
736        _ => true,
737    }
738}
739
740/// A content part's `content_type`, for diagnostics.
741fn part_kind(part: &ContentPart) -> &'static str {
742    match part {
743        ContentPart::Text { .. } => "text",
744        ContentPart::Thinking { .. } => "thinking",
745        ContentPart::ToolCall { .. } => "tool_call",
746        ContentPart::ToolResult { .. } => "tool_result",
747        ContentPart::Resource { .. } => "resource",
748        ContentPart::ResourceRef { .. } => "resource_ref",
749        ContentPart::PromptRequest { .. } => "prompt_request",
750        ContentPart::PromptResult { .. } => "prompt_result",
751        ContentPart::Image { .. } => "image",
752        ContentPart::Video { .. } => "video",
753        ContentPart::Audio { .. } => "audio",
754        ContentPart::Document { .. } => "document",
755    }
756}
757
758/// Whether a legacy tool result tells the plugin it is an error, the MCP
759/// `CallToolResult` way.
760fn result_says_error(result: &Value) -> bool {
761    result.get("isError") == Some(&Value::Bool(true))
762}
763
764fn refuse_resource_version(
765    resource: &Resource,
766    err: &dyn Fn(&str) -> HostError,
767) -> Result<(), HostError> {
768    if is_set(&resource.version) {
769        return Err(err(
770            "the resource has a `version`, which the legacy payload cannot carry; a plugin \
771             that authorizes by `uri` would approve any revision",
772        ));
773    }
774    Ok(())
775}
776
777fn to_json<T: Serialize>(value: &T, err: &dyn Fn(&str) -> HostError) -> Result<Value, HostError> {
778    serde_json::to_value(value).map_err(|e| err(&format!("could not serialize: {e}")))
779}
780
781#[cfg(test)]
782mod tests {
783    use ppe::praxis_policy_core::cmf::{
784        Channel, PromptRequest, PromptResult, Resource, ResourceReference, ResourceType, ToolCall,
785        ToolResult,
786    };
787    use serde_json::json;
788
789    use super::*;
790
791    // --- fixtures -----------------------------------------------------------
792
793    /// A text part that carries text.
794    fn context_text() -> ContentPart {
795        ContentPart::Text {
796            text: "unrelated context".into(),
797        }
798    }
799
800    /// A message with the target part between two empty parts: the only
801    /// siblings the shim accepts, so every round trip can assert they survive.
802    fn message_around(part: ContentPart) -> MessagePayload {
803        let mut message = Message::with_content(
804            Role::Assistant,
805            vec![
806                ContentPart::Text {
807                    text: String::new(),
808                },
809                part,
810                ContentPart::Thinking { text: " ".into() },
811            ],
812        );
813        message.channel = Some(Channel::Commentary);
814        MessagePayload { message }
815    }
816
817    fn tool_call(args: Value) -> ContentPart {
818        ContentPart::ToolCall {
819            content: ToolCall {
820                tool_call_id: "call-1".into(),
821                name: "search".into(),
822                arguments: serde_json::from_value(args).unwrap(),
823                namespace: None,
824            },
825        }
826    }
827
828    fn tool_result(content: Value) -> ContentPart {
829        ContentPart::ToolResult {
830            content: ToolResult {
831                tool_call_id: "call-1".into(),
832                tool_name: "search".into(),
833                content,
834                is_error: false,
835            },
836        }
837    }
838
839    fn prompt_request(args: Value) -> ContentPart {
840        ContentPart::PromptRequest {
841            content: PromptRequest {
842                prompt_request_id: "pr-1".into(),
843                name: "greeting".into(),
844                arguments: serde_json::from_value(args).unwrap(),
845                server_id: None,
846            },
847        }
848    }
849
850    fn prompt_result(messages: Vec<Message>) -> ContentPart {
851        ContentPart::PromptResult {
852            content: PromptResult {
853                prompt_request_id: "pr-1".into(),
854                prompt_name: "greeting".into(),
855                messages,
856                content: None,
857                is_error: false,
858                error_message: None,
859            },
860        }
861    }
862
863    fn resource(text: Option<&str>) -> Resource {
864        Resource {
865            resource_request_id: "res-1".into(),
866            uri: "file:///tmp/a.txt".into(),
867            name: Some("a".into()),
868            description: None,
869            resource_type: ResourceType::File,
870            content: text.map(str::to_string),
871            blob: None,
872            mime_type: Some("text/plain".into()),
873            size_bytes: Some(5),
874            annotations: HashMap::from([("k".into(), json!("v"))]),
875            version: None,
876        }
877    }
878
879    fn resource_ref() -> ContentPart {
880        ContentPart::ResourceRef {
881            content: ResourceReference {
882                resource_request_id: "res-1".into(),
883                uri: "file:///tmp/a.txt".into(),
884                name: None,
885                resource_type: ResourceType::File,
886                range_start: Some(1),
887                range_end: None,
888                selector: None,
889            },
890        }
891    }
892
893    fn json_of(payload: &MessagePayload) -> Value {
894        serde_json::to_value(payload).unwrap()
895    }
896
897    /// Assert the parts around index 1 and the message-level fields are what
898    /// `message_around` built.
899    fn assert_surroundings_kept(payload: &MessagePayload) {
900        let json = json_of(payload);
901        assert_eq!(
902            json["message"]["content"][0],
903            json!({"content_type": "text", "text": ""})
904        );
905        assert_eq!(
906            json["message"]["content"][2],
907            json!({"content_type": "thinking", "text": " "})
908        );
909        assert_eq!(json["message"]["role"], "assistant");
910        assert_eq!(json["message"]["channel"], "commentary");
911        assert_eq!(payload.message.content.len(), 3);
912    }
913
914    /// The JSON the target part serializes to, for a whole-part comparison.
915    fn part_json(payload: &MessagePayload) -> Value {
916        json_of(payload)["message"]["content"][1].clone()
917    }
918
919    fn assert_shim_error(err: HostError, hook: &str, needle: &str) {
920        let text = err.to_string();
921        assert!(matches!(err, HostError::LegacyShim { .. }), "got {err:?}");
922        assert!(text.contains(hook), "the error must name the hook: {text}");
923        assert!(text.contains(needle), "expected '{needle}' in: {text}");
924    }
925
926    // --- hook table ---------------------------------------------------------
927
928    #[test]
929    fn the_six_tool_prompt_and_resource_hooks_map_to_legacy_names() {
930        for (cmf, legacy) in [
931            ("cmf.tool_pre_invoke", "tool_pre_invoke"),
932            ("cmf.tool_post_invoke", "tool_post_invoke"),
933            ("cmf.prompt_pre_invoke", "prompt_pre_fetch"),
934            ("cmf.prompt_post_invoke", "prompt_post_fetch"),
935            ("cmf.resource_pre_fetch", "resource_pre_fetch"),
936            ("cmf.resource_post_fetch", "resource_post_fetch"),
937        ] {
938            assert_eq!(legacy_hook_name(cmf), Some(legacy), "{cmf}");
939            assert!(is_cmf_hook(cmf));
940        }
941    }
942
943    #[test]
944    fn every_cmf_hook_ppe_dispatches_is_covered() {
945        // A new upstream cmf hook must be either shimmed or refused, never
946        // forwarded under a name the old worker does not know.
947        for hook in ppe::praxis_policy_core::hooks::builtin_hook_types() {
948            let name = hook.as_str();
949            if name.starts_with("cmf.") {
950                assert!(
951                    is_cmf_hook(name),
952                    "'{name}' is not covered by the shim table"
953                );
954            }
955        }
956    }
957
958    #[test]
959    fn non_cmf_hooks_are_not_shimmed() {
960        for hook in [
961            "tool_pre_invoke",
962            "identity.resolve",
963            "token.delegate",
964            "cmf.custom",
965        ] {
966            assert!(!is_cmf_hook(hook), "{hook}");
967            assert_eq!(legacy_hook_name(hook), None, "{hook}");
968        }
969    }
970
971    // --- cmf.tool_pre_invoke ------------------------------------------------
972
973    #[test]
974    fn tool_pre_invoke_sends_name_and_args() {
975        let payload = message_around(tool_call(json!({"q": "rust", "n": 3})));
976        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
977
978        assert_eq!(shimmed.legacy_hook(), "tool_pre_invoke");
979        assert_eq!(
980            shimmed.payload(),
981            &json!({"name": "search", "args": {"q": "rust", "n": 3}})
982        );
983    }
984
985    #[test]
986    fn tool_pre_invoke_unchanged_round_trip_preserves_the_message() {
987        let payload = message_around(tool_call(json!({"q": "rust"})));
988        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
989
990        // What the 0.1.x worker's `model_dump(mode="json")` returns for an
991        // unmodified payload: every field, `headers` as null.
992        let back = shimmed
993            .apply_modified(json!({"name": "search", "args": {"q": "rust"}, "headers": null}))
994            .unwrap();
995        assert_eq!(json_of(&back), json_of(&payload));
996    }
997
998    #[test]
999    fn tool_pre_invoke_modified_args_map_back() {
1000        let payload = message_around(tool_call(json!({"q": "alice@example.com"})));
1001        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1002
1003        let back = shimmed
1004            .apply_modified(
1005                json!({"name": "search", "args": {"q": "[REDACTED]", "extra": 1}, "headers": {}}),
1006            )
1007            .unwrap();
1008        assert_eq!(
1009            part_json(&back),
1010            json!({"content_type": "tool_call", "content": {
1011                "tool_call_id": "call-1",
1012                "name": "search",
1013                "arguments": {"q": "[REDACTED]", "extra": 1},
1014            }})
1015        );
1016        assert_surroundings_kept(&back);
1017    }
1018
1019    #[test]
1020    fn tool_pre_invoke_returned_headers_fail_closed() {
1021        let payload = message_around(tool_call(json!({})));
1022        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1023        let err = shimmed
1024            .apply_modified(json!({"name": "search", "args": {}, "headers": {"X-Injected": "1"}}))
1025            .unwrap_err();
1026        assert_shim_error(err, "cmf.tool_pre_invoke", "headers");
1027    }
1028
1029    #[test]
1030    fn tool_pre_invoke_without_a_tool_call_fails_closed() {
1031        let payload = MessagePayload {
1032            message: Message::text(Role::User, "what is the weather?"),
1033        };
1034        let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1035        assert_shim_error(err, "cmf.tool_pre_invoke", "no tool_call content part");
1036    }
1037
1038    #[test]
1039    fn tool_pre_invoke_with_two_tool_calls_fails_closed() {
1040        let payload = MessagePayload {
1041            message: Message::with_content(
1042                Role::Assistant,
1043                vec![tool_call(json!({})), tool_call(json!({}))],
1044            ),
1045        };
1046        let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1047        assert_shim_error(err, "cmf.tool_pre_invoke", "2 tool_call content parts");
1048    }
1049
1050    #[test]
1051    fn a_modified_payload_missing_required_fields_is_a_protocol_error() {
1052        let payload = message_around(tool_call(json!({})));
1053        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1054        let err = shimmed.apply_modified(json!({"args": {}})).unwrap_err();
1055        assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
1056        assert!(err.to_string().contains("tool_pre_invoke"), "{err}");
1057    }
1058
1059    // --- cmf.tool_post_invoke -----------------------------------------------
1060
1061    #[test]
1062    fn tool_post_invoke_sends_name_and_result() {
1063        let result = json!({"content": [{"type": "text", "text": "3 hits"}], "isError": false});
1064        let payload = message_around(tool_result(result.clone()));
1065        let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1066
1067        assert_eq!(shimmed.legacy_hook(), "tool_post_invoke");
1068        assert_eq!(
1069            shimmed.payload(),
1070            &json!({"name": "search", "result": result})
1071        );
1072
1073        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1074        assert_eq!(json_of(&back), json_of(&payload));
1075    }
1076
1077    #[test]
1078    fn tool_post_invoke_modified_result_maps_back() {
1079        let payload = message_around(tool_result(json!({"text": "ssn 123-45-6789"})));
1080        let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1081
1082        let back = shimmed
1083            .apply_modified(json!({"name": "search", "result": {"text": "ssn [REDACTED]"}}))
1084            .unwrap();
1085        assert_eq!(
1086            part_json(&back),
1087            json!({"content_type": "tool_result", "content": {
1088                "tool_call_id": "call-1",
1089                "tool_name": "search",
1090                "content": {"text": "ssn [REDACTED]"},
1091                "is_error": false,
1092            }})
1093        );
1094        assert_surroundings_kept(&back);
1095    }
1096
1097    #[test]
1098    fn tool_post_invoke_without_a_tool_result_fails_closed() {
1099        let payload = message_around(tool_call(json!({})));
1100        let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1101        assert_shim_error(err, "cmf.tool_post_invoke", "no tool_result content part");
1102    }
1103
1104    // --- cmf.prompt_pre_invoke ----------------------------------------------
1105
1106    #[test]
1107    fn prompt_pre_invoke_sends_prompt_id_and_string_args() {
1108        let payload = message_around(prompt_request(json!({"lang": "en"})));
1109        let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap();
1110
1111        assert_eq!(shimmed.legacy_hook(), "prompt_pre_fetch");
1112        assert_eq!(
1113            shimmed.payload(),
1114            &json!({"prompt_id": "greeting", "args": {"lang": "en"}})
1115        );
1116
1117        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1118        assert_eq!(json_of(&back), json_of(&payload));
1119    }
1120
1121    #[test]
1122    fn prompt_pre_invoke_modified_args_map_back() {
1123        let payload = message_around(prompt_request(json!({"lang": "en", "who": "bob"})));
1124        let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap();
1125
1126        let back = shimmed
1127            .apply_modified(json!({"prompt_id": "greeting", "args": {"lang": "fr"}}))
1128            .unwrap();
1129        assert_eq!(
1130            part_json(&back),
1131            json!({"content_type": "prompt_request", "content": {
1132                "prompt_request_id": "pr-1",
1133                "name": "greeting",
1134                "arguments": {"lang": "fr"},
1135            }})
1136        );
1137        assert_surroundings_kept(&back);
1138    }
1139
1140    #[test]
1141    fn prompt_pre_invoke_with_a_non_string_argument_fails_closed() {
1142        let payload = message_around(prompt_request(json!({"count": 5})));
1143        let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err();
1144        assert_shim_error(err, "cmf.prompt_pre_invoke", "'count' is not a string");
1145    }
1146
1147    #[test]
1148    fn prompt_pre_invoke_without_a_prompt_request_fails_closed() {
1149        let payload = message_around(tool_call(json!({})));
1150        let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err();
1151        assert_shim_error(
1152            err,
1153            "cmf.prompt_pre_invoke",
1154            "no prompt_request content part",
1155        );
1156    }
1157
1158    // --- cmf.prompt_post_invoke ---------------------------------------------
1159
1160    #[test]
1161    fn prompt_post_invoke_sends_mcp_shaped_messages() {
1162        let payload = message_around(prompt_result(vec![
1163            Message::text(Role::System, "be brief"),
1164            Message::text(Role::User, "hi bob"),
1165        ]));
1166        let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1167
1168        assert_eq!(shimmed.legacy_hook(), "prompt_post_fetch");
1169        assert_eq!(
1170            shimmed.payload(),
1171            &json!({"prompt_id": "greeting", "result": {
1172                "messages": [
1173                    {"role": "system", "content": {"type": "text", "text": "be brief"}},
1174                    {"role": "user", "content": {"type": "text", "text": "hi bob"}},
1175                ],
1176                "description": null,
1177            }})
1178        );
1179
1180        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1181        assert_eq!(json_of(&back), json_of(&payload));
1182    }
1183
1184    #[test]
1185    fn prompt_post_invoke_modified_messages_map_back() {
1186        let mut first = Message::text(Role::User, "hi bob");
1187        first.channel = Some(Channel::Final);
1188        let payload = message_around(prompt_result(vec![first]));
1189        let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1190
1191        // Pydantic dumps the fixture TextContent's extra fields; they are
1192        // tolerated and dropped.
1193        let back = shimmed
1194            .apply_modified(json!({"prompt_id": "greeting", "result": {
1195                "messages": [
1196                    {"role": "user", "content": {"type": "text", "text": "hi [NAME]", "annotations": null}},
1197                    {"role": "assistant", "content": {"type": "text", "text": "appended"}},
1198                ],
1199                "description": null,
1200            }}))
1201            .unwrap();
1202
1203        let part = part_json(&back);
1204        assert_eq!(
1205            part["content"]["prompt_request_id"], "pr-1",
1206            "unmapped fields survive"
1207        );
1208        let messages = &part["content"]["messages"];
1209        assert_eq!(messages[0]["content"][0]["text"], "hi [NAME]");
1210        assert_eq!(
1211            messages[0]["channel"], "final",
1212            "the original message's channel is kept"
1213        );
1214        assert_eq!(messages[1]["role"], "assistant");
1215        assert_eq!(messages[1]["content"][0]["text"], "appended");
1216        assert_surroundings_kept(&back);
1217    }
1218
1219    #[test]
1220    fn prompt_post_invoke_with_a_multipart_message_fails_closed() {
1221        let payload = message_around(prompt_result(vec![Message::with_content(
1222            Role::User,
1223            vec![context_text(), context_text()],
1224        )]));
1225        let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1226        assert_shim_error(err, "cmf.prompt_post_invoke", "not a single text part");
1227    }
1228
1229    #[test]
1230    fn prompt_post_invoke_returned_description_fails_closed() {
1231        let payload = message_around(prompt_result(vec![]));
1232        let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1233        let err = shimmed
1234            .apply_modified(
1235                json!({"prompt_id": "greeting", "result": {"messages": [], "description": "x"}}),
1236            )
1237            .unwrap_err();
1238        assert_shim_error(err, "cmf.prompt_post_invoke", "description");
1239    }
1240
1241    #[test]
1242    fn prompt_post_invoke_returned_non_text_content_fails_closed() {
1243        let payload = message_around(prompt_result(vec![]));
1244        let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1245        let err = shimmed
1246            .apply_modified(json!({"prompt_id": "greeting", "result": {"messages": [
1247                {"role": "user", "content": {"type": "image", "data": "..."}}
1248            ]}}))
1249            .unwrap_err();
1250        assert_shim_error(err, "cmf.prompt_post_invoke", "messages[0].content");
1251    }
1252
1253    // --- cmf.resource_pre_fetch ---------------------------------------------
1254
1255    #[test]
1256    fn resource_pre_fetch_sends_the_uri_from_a_reference() {
1257        let payload = message_around(resource_ref());
1258        let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1259
1260        assert_eq!(shimmed.legacy_hook(), "resource_pre_fetch");
1261        assert_eq!(shimmed.payload(), &json!({"uri": "file:///tmp/a.txt"}));
1262
1263        // `metadata: {}` is what Pydantic's default dumps as.
1264        let back = shimmed
1265            .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}}))
1266            .unwrap();
1267        assert_eq!(json_of(&back), json_of(&payload));
1268    }
1269
1270    /// A resource with no fetched content yet: nothing describes the URI
1271    /// beyond labels.
1272    fn unfetched_resource() -> Resource {
1273        Resource {
1274            mime_type: None,
1275            size_bytes: None,
1276            ..resource(None)
1277        }
1278    }
1279
1280    #[test]
1281    fn resource_pre_fetch_modified_uri_maps_back_onto_an_unfetched_resource() {
1282        let payload = message_around(ContentPart::Resource {
1283            content: unfetched_resource(),
1284        });
1285        let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1286
1287        let back = shimmed
1288            .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}}))
1289            .unwrap();
1290        let mut expected = unfetched_resource();
1291        expected.uri = "file:///safe/a.txt".into();
1292        assert_eq!(
1293            part_json(&back),
1294            serde_json::to_value(ContentPart::Resource { content: expected }).unwrap()
1295        );
1296        assert_surroundings_kept(&back);
1297    }
1298
1299    #[test]
1300    fn resource_pre_fetch_returned_metadata_fails_closed() {
1301        let payload = message_around(resource_ref());
1302        let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1303        let err = shimmed
1304            .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {"etag": "x"}}))
1305            .unwrap_err();
1306        assert_shim_error(err, "cmf.resource_pre_fetch", "metadata");
1307    }
1308
1309    #[test]
1310    fn resource_pre_fetch_without_a_resource_fails_closed() {
1311        let payload = MessagePayload {
1312            message: Message::text(Role::User, "no resource here"),
1313        };
1314        let err = to_legacy("cmf.resource_pre_fetch", &payload).unwrap_err();
1315        assert_shim_error(err, "cmf.resource_pre_fetch", "no resource or resource_ref");
1316    }
1317
1318    // --- cmf.resource_post_fetch --------------------------------------------
1319
1320    #[test]
1321    fn resource_post_fetch_sends_legacy_resource_content() {
1322        let payload = message_around(ContentPart::Resource {
1323            content: resource(Some("hello")),
1324        });
1325        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1326
1327        assert_eq!(shimmed.legacy_hook(), "resource_post_fetch");
1328        assert_eq!(
1329            shimmed.payload(),
1330            &json!({"uri": "file:///tmp/a.txt", "content": {
1331                "type": "resource",
1332                "id": "res-1",
1333                "uri": "file:///tmp/a.txt",
1334                "mime_type": "text/plain",
1335                "text": "hello",
1336            }})
1337        );
1338
1339        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1340        assert_eq!(json_of(&back), json_of(&payload));
1341    }
1342
1343    #[test]
1344    fn resource_post_fetch_modified_content_maps_back() {
1345        let payload = message_around(ContentPart::Resource {
1346            content: resource(Some("password=hunter2")),
1347        });
1348        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1349
1350        let back = shimmed
1351            .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1352                "type": "resource", "id": "res-1", "uri": "file:///tmp/a.txt",
1353                "mime_type": "text/plain", "text": "password=[REDACTED]", "blob": null,
1354            }}))
1355            .unwrap();
1356        let mut expected = resource(Some("password=[REDACTED]"));
1357        expected.size_bytes = Some("password=[REDACTED]".len() as u64);
1358        assert_eq!(
1359            part_json(&back),
1360            serde_json::to_value(ContentPart::Resource { content: expected }).unwrap(),
1361            "the text changes and the size follows it; name and annotations survive"
1362        );
1363        assert_surroundings_kept(&back);
1364    }
1365
1366    #[test]
1367    fn resource_post_fetch_accepts_plain_string_content() {
1368        let payload = message_around(ContentPart::Resource {
1369            content: resource(Some("a")),
1370        });
1371        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1372        let back = shimmed
1373            .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": "b"}))
1374            .unwrap();
1375        assert_eq!(part_json(&back)["content"]["content"], "b");
1376    }
1377
1378    #[test]
1379    fn resource_post_fetch_with_a_blob_fails_closed() {
1380        let mut binary = resource(None);
1381        binary.blob = Some(vec![0, 1, 2]);
1382        let payload = message_around(ContentPart::Resource { content: binary });
1383        let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err();
1384        assert_shim_error(err, "cmf.resource_post_fetch", "blob");
1385    }
1386
1387    #[test]
1388    fn resource_post_fetch_with_only_a_reference_fails_closed() {
1389        let payload = message_around(resource_ref());
1390        let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err();
1391        assert_shim_error(
1392            err,
1393            "cmf.resource_post_fetch",
1394            "resource_ref with no content",
1395        );
1396    }
1397
1398    #[test]
1399    fn resource_post_fetch_returned_blob_fails_closed() {
1400        let payload = message_around(ContentPart::Resource {
1401            content: resource(Some("a")),
1402        });
1403        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1404        let err = shimmed
1405            .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {"blob": "AAEC"}}))
1406            .unwrap_err();
1407        assert_shim_error(err, "cmf.resource_post_fetch", "blob");
1408    }
1409
1410    // --- fields the legacy payload cannot carry ------------------------------
1411
1412    #[test]
1413    fn tool_pre_invoke_with_a_namespace_fails_closed() {
1414        let mut part = tool_call(json!({}));
1415        if let ContentPart::ToolCall { content } = &mut part {
1416            content.namespace = Some("other-tenant".into());
1417        }
1418        let err = to_legacy("cmf.tool_pre_invoke", &message_around(part)).unwrap_err();
1419        assert_shim_error(err, "cmf.tool_pre_invoke", "namespace");
1420    }
1421
1422    #[test]
1423    fn tool_pre_invoke_with_an_empty_namespace_is_unqualified() {
1424        let mut part = tool_call(json!({}));
1425        if let ContentPart::ToolCall { content } = &mut part {
1426            content.namespace = Some(String::new());
1427        }
1428        let payload = message_around(part);
1429        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1430        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1431        assert_eq!(json_of(&back), json_of(&payload));
1432    }
1433
1434    fn error_tool_result(content: Value) -> ContentPart {
1435        let mut part = tool_result(content);
1436        if let ContentPart::ToolResult { content } = &mut part {
1437            content.is_error = true;
1438        }
1439        part
1440    }
1441
1442    #[test]
1443    fn tool_post_invoke_error_the_result_does_not_show_fails_closed() {
1444        let payload = message_around(error_tool_result(json!({"text": "boom"})));
1445        let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1446        assert_shim_error(err, "cmf.tool_post_invoke", "is_error");
1447    }
1448
1449    #[test]
1450    fn tool_post_invoke_error_the_result_shows_round_trips() {
1451        let payload = message_around(error_tool_result(json!({"content": [], "isError": true})));
1452        let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1453        let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1454        assert_eq!(json_of(&back), json_of(&payload));
1455    }
1456
1457    #[test]
1458    fn tool_post_invoke_clearing_is_error_fails_closed() {
1459        let payload = message_around(error_tool_result(json!({"content": [], "isError": true})));
1460        let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1461        let err = shimmed
1462            .apply_modified(json!({"name": "search", "result": {"content": [], "isError": false}}))
1463            .unwrap_err();
1464        assert_shim_error(err, "cmf.tool_post_invoke", "isError");
1465    }
1466
1467    #[test]
1468    fn prompt_pre_invoke_with_a_server_id_fails_closed() {
1469        let mut part = prompt_request(json!({}));
1470        if let ContentPart::PromptRequest { content } = &mut part {
1471            content.server_id = Some("other-server".into());
1472        }
1473        let err = to_legacy("cmf.prompt_pre_invoke", &message_around(part)).unwrap_err();
1474        assert_shim_error(err, "cmf.prompt_pre_invoke", "server_id");
1475    }
1476
1477    fn prompt_result_with(edit: impl FnOnce(&mut PromptResult)) -> MessagePayload {
1478        let mut part = prompt_result(vec![Message::text(Role::User, "hi bob")]);
1479        if let ContentPart::PromptResult { content } = &mut part {
1480            edit(content);
1481        }
1482        message_around(part)
1483    }
1484
1485    #[test]
1486    fn prompt_post_invoke_with_rendered_content_fails_closed() {
1487        // A plugin that redacts `messages` would leave "hi bob" here.
1488        let payload = prompt_result_with(|r| r.content = Some("hi bob".into()));
1489        let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1490        assert_shim_error(err, "cmf.prompt_post_invoke", "`content`");
1491    }
1492
1493    #[test]
1494    fn prompt_post_invoke_with_an_error_message_fails_closed() {
1495        let payload = prompt_result_with(|r| r.error_message = Some("failed for bob".into()));
1496        let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1497        assert_shim_error(err, "cmf.prompt_post_invoke", "error_message");
1498    }
1499
1500    #[test]
1501    fn prompt_post_invoke_with_is_error_fails_closed() {
1502        let payload = prompt_result_with(|r| r.is_error = true);
1503        let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1504        assert_shim_error(err, "cmf.prompt_post_invoke", "is_error");
1505    }
1506
1507    #[test]
1508    fn resource_hooks_with_a_version_fail_closed() {
1509        let mut versioned = resource(Some("a"));
1510        versioned.version = Some("v1".into());
1511        let payload = message_around(ContentPart::Resource { content: versioned });
1512        for hook in ["cmf.resource_pre_fetch", "cmf.resource_post_fetch"] {
1513            let err = to_legacy(hook, &payload).unwrap_err();
1514            assert_shim_error(err, hook, "version");
1515        }
1516    }
1517
1518    #[test]
1519    fn resource_pre_fetch_unchanged_uri_keeps_embedded_content() {
1520        let payload = message_around(ContentPart::Resource {
1521            content: resource(Some("hello")),
1522        });
1523        let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1524        let back = shimmed
1525            .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}}))
1526            .unwrap();
1527        assert_eq!(json_of(&back), json_of(&payload));
1528    }
1529
1530    #[test]
1531    fn resource_pre_fetch_uri_rewrite_on_fetched_content_fails_closed() {
1532        let embedded = resource(Some("hello"));
1533        let mut sized = unfetched_resource();
1534        sized.size_bytes = Some(5);
1535        let mut typed = unfetched_resource();
1536        typed.mime_type = Some("text/plain".into());
1537        let mut binary = unfetched_resource();
1538        binary.blob = Some(vec![1]);
1539        for res in [embedded, sized, typed, binary] {
1540            let payload = message_around(ContentPart::Resource { content: res });
1541            let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1542            let err = shimmed
1543                .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}}))
1544                .unwrap_err();
1545            assert_shim_error(err, "cmf.resource_pre_fetch", "rewrote the URI");
1546        }
1547    }
1548
1549    #[test]
1550    fn resource_post_fetch_changed_content_id_fails_closed() {
1551        let payload = message_around(ContentPart::Resource {
1552            content: resource(Some("a")),
1553        });
1554        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1555        let err = shimmed
1556            .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1557                "id": "res-2", "uri": "file:///tmp/a.txt", "text": "a",
1558            }}))
1559            .unwrap_err();
1560        assert_shim_error(err, "cmf.resource_post_fetch", "content.id");
1561    }
1562
1563    #[test]
1564    fn resource_post_fetch_content_uri_alone_changed_fails_closed() {
1565        let payload = message_around(ContentPart::Resource {
1566            content: resource(Some("a")),
1567        });
1568        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1569        let err = shimmed
1570            .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1571                "id": "res-1", "uri": "file:///safe/a.txt", "text": "a",
1572            }}))
1573            .unwrap_err();
1574        assert_shim_error(err, "cmf.resource_post_fetch", "content.uri");
1575    }
1576
1577    #[test]
1578    fn resource_post_fetch_top_level_uri_change_maps_back() {
1579        let payload = message_around(ContentPart::Resource {
1580            content: resource(Some("a")),
1581        });
1582        let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1583        let back = shimmed
1584            .apply_modified(json!({"uri": "file:///safe/a.txt", "content": {
1585                "id": "res-1", "uri": "file:///tmp/a.txt", "text": "a",
1586            }}))
1587            .unwrap();
1588        assert_eq!(part_json(&back)["content"]["uri"], "file:///safe/a.txt");
1589        assert_eq!(
1590            part_json(&back)["content"]["size_bytes"],
1591            5,
1592            "text unchanged"
1593        );
1594    }
1595
1596    #[test]
1597    fn tool_post_invoke_a_plugin_setting_is_error_sets_the_flag() {
1598        let payload = message_around(tool_result(json!({"content": [], "isError": false})));
1599        let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1600        let back = shimmed
1601            .apply_modified(json!({"name": "search", "result": {"content": [], "isError": true}}))
1602            .unwrap();
1603        assert_eq!(part_json(&back)["content"]["is_error"], true);
1604        assert_eq!(part_json(&back)["content"]["content"]["isError"], true);
1605    }
1606
1607    // --- sibling content parts ---------------------------------------------
1608
1609    /// Every shimmed hook, with a message holding just its part.
1610    fn each_hook_with_its_part() -> Vec<(&'static str, ContentPart)> {
1611        vec![
1612            ("cmf.tool_pre_invoke", tool_call(json!({}))),
1613            ("cmf.tool_post_invoke", tool_result(json!({"text": "ok"}))),
1614            ("cmf.prompt_pre_invoke", prompt_request(json!({}))),
1615            ("cmf.prompt_post_invoke", prompt_result(vec![])),
1616            ("cmf.resource_pre_fetch", resource_ref()),
1617            (
1618                "cmf.resource_post_fetch",
1619                ContentPart::Resource {
1620                    content: resource(Some("a")),
1621                },
1622            ),
1623        ]
1624    }
1625
1626    /// Whether a shimmed hook is a post hook.
1627    fn is_post_hook(hook: &str) -> bool {
1628        hook.contains("_post_")
1629    }
1630
1631    /// Sibling parts that carry text.
1632    fn texty_siblings() -> [ContentPart; 2] {
1633        [
1634            ContentPart::Text {
1635                text: "SSN 123-45-6789".into(),
1636            },
1637            ContentPart::Thinking {
1638                text: "SSN 123-45-6789".into(),
1639            },
1640        ]
1641    }
1642
1643    #[test]
1644    fn a_sibling_text_part_fails_closed_on_post_hooks() {
1645        // The plugin would approve the one result part it sees while the text
1646        // beside it went downstream unredacted.
1647        for (hook, part) in each_hook_with_its_part()
1648            .into_iter()
1649            .filter(|(h, _)| is_post_hook(h))
1650        {
1651            for sibling in texty_siblings() {
1652                let payload = MessagePayload {
1653                    message: Message::with_content(Role::Assistant, vec![sibling, part.clone()]),
1654                };
1655                let err = to_legacy(hook, &payload).unwrap_err();
1656                assert_shim_error(err, hook, "beside the");
1657            }
1658        }
1659    }
1660
1661    #[test]
1662    fn a_sibling_text_part_passes_through_on_pre_hooks() {
1663        // The plugin authorizes the part it sees. Narration beside it is
1664        // returned unchanged, through both an allow and a modification.
1665        for (hook, part) in each_hook_with_its_part()
1666            .into_iter()
1667            .filter(|(h, _)| !is_post_hook(h))
1668        {
1669            for sibling in texty_siblings() {
1670                let payload = MessagePayload {
1671                    message: Message::with_content(
1672                        Role::Assistant,
1673                        vec![sibling.clone(), part.clone()],
1674                    ),
1675                };
1676                let shimmed = to_legacy(hook, &payload).unwrap();
1677                let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1678                assert_eq!(back.message.content.len(), 2, "{hook}");
1679                assert_eq!(
1680                    serde_json::to_value(&back.message.content[0]).unwrap(),
1681                    serde_json::to_value(&sibling).unwrap(),
1682                    "{hook}"
1683                );
1684                assert_eq!(
1685                    json_of(&back),
1686                    json_of(&payload),
1687                    "{hook}: an unmodified round trip changes nothing"
1688                );
1689            }
1690        }
1691    }
1692
1693    #[test]
1694    fn a_pre_hook_rewrite_keeps_the_narration_sibling() {
1695        let narration = ContentPart::Text {
1696            text: "Let me look that up.".into(),
1697        };
1698        let payload = MessagePayload {
1699            message: Message::with_content(
1700                Role::Assistant,
1701                vec![narration.clone(), tool_call(json!({"q": "a"}))],
1702            ),
1703        };
1704        let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1705        let back = shimmed
1706            .apply_modified(json!({"name": "search", "args": {"q": "b"}}))
1707            .unwrap();
1708        let json = json_of(&back);
1709        assert_eq!(
1710            json["message"]["content"][0],
1711            serde_json::to_value(&narration).unwrap()
1712        );
1713        assert_eq!(
1714            json["message"]["content"][1]["content"]["arguments"]["q"],
1715            "b"
1716        );
1717    }
1718
1719    #[test]
1720    fn a_sibling_part_of_another_kind_fails_closed() {
1721        let payload = MessagePayload {
1722            message: Message::with_content(
1723                Role::Assistant,
1724                vec![
1725                    tool_result(json!({"text": "ok"})),
1726                    ContentPart::ToolCall {
1727                        content: ToolCall {
1728                            tool_call_id: "call-2".into(),
1729                            name: "other".into(),
1730                            arguments: HashMap::new(),
1731                            namespace: None,
1732                        },
1733                    },
1734                ],
1735            ),
1736        };
1737        let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1738        assert_shim_error(err, "cmf.tool_post_invoke", "tool_call content part beside");
1739    }
1740
1741    #[test]
1742    fn a_sibling_refusal_names_no_content() {
1743        let payload = MessagePayload {
1744            message: Message::with_content(
1745                Role::Assistant,
1746                vec![
1747                    ContentPart::Text {
1748                        text: "SECRET-SIBLING-TEXT".into(),
1749                    },
1750                    tool_result(json!({"text": "ok"})),
1751                ],
1752            ),
1753        };
1754        let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1755        assert!(!err.to_string().contains("SECRET-SIBLING-TEXT"), "{err}");
1756    }
1757
1758    #[test]
1759    fn empty_sibling_parts_are_kept() {
1760        for (hook, part) in each_hook_with_its_part() {
1761            let payload = message_around(part);
1762            let shimmed = to_legacy(hook, &payload).unwrap();
1763            let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1764            assert_surroundings_kept(&back);
1765        }
1766    }
1767
1768    // --- refusals -----------------------------------------------------------
1769
1770    #[test]
1771    fn llm_hooks_have_no_legacy_equivalent_and_fail_closed() {
1772        let payload = MessagePayload {
1773            message: Message::text(Role::User, "hello"),
1774        };
1775        for hook in ["cmf.llm_input", "cmf.llm_output"] {
1776            assert!(is_cmf_hook(hook));
1777            let err = to_legacy(hook, &payload).unwrap_err();
1778            assert_shim_error(err, hook, "cmf_hooks");
1779        }
1780    }
1781
1782    #[test]
1783    fn a_non_message_payload_fails_closed() {
1784        let payload = ToolPreInvokePayload {
1785            name: "search".into(),
1786            ..Default::default()
1787        };
1788        let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1789        assert_shim_error(err, "cmf.tool_pre_invoke", "not a CMF MessagePayload");
1790    }
1791
1792    #[test]
1793    fn shim_errors_carry_no_payload_values() {
1794        // Error text reaches logs. A refusal must name shapes, not content.
1795        let payload = MessagePayload {
1796            message: Message::text(Role::User, "SECRET-USER-TEXT"),
1797        };
1798        let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1799        assert!(!err.to_string().contains("SECRET-USER-TEXT"), "{err}");
1800    }
1801}