Skip to main content

cpex_hosts_python/legacy/
payloads.rs

1// Location: ./crates/cpex-hosts-python/src/legacy/payloads.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Typed payloads for the eight legacy (non-CMF) hooks.
7//
8// Each struct's serialized JSON must match the Pydantic model `worker.py`
9// reconstructs via `json_to_payload`, field for field — a name mismatch
10// surfaces as a Pydantic validation error inside the worker, at invoke time,
11// far from its cause. The Python model each one mirrors is named in its doc
12// comment; the field-shape tests in this module are the guard.
13//
14// # Redaction
15//
16// `IdentityResolvePayload` and `TokenDelegatePayload` carry token-adjacent
17// fields, so both get a hand-written `Debug` that prints a placeholder instead
18// of the value. A derive would put credential material into any `{:?}` — a
19// tracing call, an `unwrap()` panic message, an assertion failure. The
20// equivalent gap on the pre-existing praxis-policy-core types is deferred follow-up
21// work; these new types do not reintroduce it.
22
23use std::collections::HashMap;
24
25use serde::{Deserialize, Serialize};
26
27/// Placeholder printed in place of any token-adjacent value.
28const REDACTED: &str = "<redacted>";
29
30/// `tool_pre_invoke` — mirrors `ToolPreInvokePayload`.
31///
32/// `headers` mirrors `HttpHeaderPayload`, which serializes as a plain string
33/// map.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct ToolPreInvokePayload {
36    pub name: String,
37
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub args: Option<HashMap<String, serde_json::Value>>,
40
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub headers: Option<HashMap<String, String>>,
43}
44ppe::praxis_policy_core::impl_plugin_payload!(ToolPreInvokePayload);
45
46/// `tool_post_invoke` — mirrors `ToolPostInvokePayload`.
47///
48/// `result` is `Any` on the Python side, so it stays an untyped JSON value.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ToolPostInvokePayload {
51    pub name: String,
52
53    #[serde(default)]
54    pub result: serde_json::Value,
55}
56ppe::praxis_policy_core::impl_plugin_payload!(ToolPostInvokePayload);
57
58/// `prompt_pre_fetch` — mirrors `PromptPrehookPayload`.
59///
60/// Note `args` is `dict[str, str]` here, not `dict[str, Any]` as on the tool
61/// payload.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct PromptPreFetchPayload {
64    pub prompt_id: String,
65
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub args: Option<HashMap<String, String>>,
68}
69ppe::praxis_policy_core::impl_plugin_payload!(PromptPreFetchPayload);
70
71/// `prompt_post_fetch` — mirrors `PromptPosthookPayload`.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct PromptPostFetchPayload {
74    pub prompt_id: String,
75
76    #[serde(default)]
77    pub result: serde_json::Value,
78}
79ppe::praxis_policy_core::impl_plugin_payload!(PromptPostFetchPayload);
80
81/// `resource_pre_fetch` — mirrors `ResourcePreFetchPayload`.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct ResourcePreFetchPayload {
84    pub uri: String,
85
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub metadata: Option<HashMap<String, serde_json::Value>>,
88}
89ppe::praxis_policy_core::impl_plugin_payload!(ResourcePreFetchPayload);
90
91/// `resource_post_fetch` — mirrors `ResourcePostFetchPayload`.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct ResourcePostFetchPayload {
94    pub uri: String,
95
96    #[serde(default)]
97    pub content: serde_json::Value,
98}
99ppe::praxis_policy_core::impl_plugin_payload!(ResourcePostFetchPayload);
100
101/// `identity.resolve` mirrors `IdentityPayload`. The worker knows the hook as
102/// `identity_resolve`.
103///
104/// `raw_token` is `SecretStr` on the Python side and redacts on serialization,
105/// so the value that travels in *this* field is a placeholder, never the real
106/// token. The plaintext rides the separate capability-gated `credential`
107/// object on the task (see the `credentials` module) and the worker folds it
108/// back onto the payload before calling the plugin.
109///
110/// `Debug` is hand-written — see the module docs.
111#[derive(Clone, Default, Serialize, Deserialize)]
112pub struct IdentityResolvePayload {
113    /// Redacted on the wire. Populated from the `credential` object by the
114    /// worker, not from this field.
115    #[serde(default)]
116    pub raw_token: String,
117
118    /// How the credential was presented — `bearer`, `custom`, and so on.
119    #[serde(default)]
120    pub source: String,
121
122    /// Headers the credential arrived in. Not a `SecretStr` on the Python
123    /// side, so the worker scrubs the plaintext out of these values before a
124    /// plugin can echo them back.
125    #[serde(default)]
126    pub headers: HashMap<String, String>,
127
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub client_host: Option<String>,
130
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub client_port: Option<u16>,
133}
134ppe::praxis_policy_core::impl_plugin_payload!(IdentityResolvePayload);
135
136impl std::fmt::Debug for IdentityResolvePayload {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        // `raw_token` and every header value are redacted: a header can carry
139        // the credential ("Authorization: Bearer <token>"), so printing keys
140        // alone is the safe maximum.
141        f.debug_struct("IdentityResolvePayload")
142            .field("raw_token", &REDACTED)
143            .field("source", &self.source)
144            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
145            .field("client_host", &self.client_host)
146            .field("client_port", &self.client_port)
147            .finish()
148    }
149}
150
151/// Scope-attenuation config — mirrors `AttenuationConfig`.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct AttenuationConfig {
154    #[serde(default)]
155    pub capabilities: Vec<String>,
156
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub resource_template: Option<String>,
159
160    #[serde(default)]
161    pub actions: Vec<String>,
162
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub ttl_seconds: Option<i64>,
165}
166
167/// `token.delegate` mirrors `DelegationPayload`. The worker knows the hook as
168/// `token_delegate`.
169///
170/// `bearer_token` is `SecretStr | None` on the Python side and redacts on
171/// serialization, so this field carries a placeholder. The plaintext travels
172/// via the `credential` object.
173///
174/// `Debug` is hand-written — see the module docs.
175#[derive(Clone, Default, Serialize, Deserialize)]
176pub struct TokenDelegatePayload {
177    pub target_name: String,
178
179    #[serde(default = "default_target_type")]
180    pub target_type: String,
181
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub target_audience: Option<String>,
184
185    #[serde(default)]
186    pub required_permissions: Vec<String>,
187
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub trust_domain: Option<String>,
190
191    #[serde(default = "default_auth_enforced_by")]
192    pub auth_enforced_by: String,
193
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub route_attenuation: Option<AttenuationConfig>,
196
197    /// Redacted on the wire; the plaintext comes from the `credential` object.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub bearer_token: Option<String>,
200}
201ppe::praxis_policy_core::impl_plugin_payload!(TokenDelegatePayload);
202
203fn default_target_type() -> String {
204    "tool".to_string()
205}
206
207fn default_auth_enforced_by() -> String {
208    "caller".to_string()
209}
210
211impl std::fmt::Debug for TokenDelegatePayload {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("TokenDelegatePayload")
214            .field("target_name", &self.target_name)
215            .field("target_type", &self.target_type)
216            .field("target_audience", &self.target_audience)
217            .field("required_permissions", &self.required_permissions)
218            .field("trust_domain", &self.trust_domain)
219            .field("auth_enforced_by", &self.auth_enforced_by)
220            .field("route_attenuation", &self.route_attenuation)
221            // Presence is useful for diagnosis; the value never is.
222            .field(
223                "bearer_token",
224                &self.bearer_token.as_ref().map(|_| REDACTED),
225            )
226            .finish()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    /// Serialize and return the JSON object, so field names can be asserted
235    /// against the Pydantic models `worker.py` reconstructs.
236    fn json_of<T: Serialize>(value: &T) -> serde_json::Value {
237        serde_json::to_value(value).expect("payload serializes")
238    }
239
240    #[test]
241    fn tool_pre_invoke_matches_the_python_field_shape() {
242        let payload = ToolPreInvokePayload {
243            name: "search".into(),
244            args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
245            headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
246        };
247        let json = json_of(&payload);
248
249        assert_eq!(json["name"], "search");
250        assert_eq!(json["args"]["q"], "rust");
251        assert_eq!(json["headers"]["X-Tenant"], "acme");
252    }
253
254    #[test]
255    fn tool_pre_invoke_omits_absent_optionals() {
256        // Pydantic defaults `args` to {} and `headers` to None. Emitting
257        // explicit nulls would override those defaults on the Python side, so
258        // absent fields must stay absent.
259        let json = json_of(&ToolPreInvokePayload {
260            name: "search".into(),
261            ..Default::default()
262        });
263        assert_eq!(
264            json.as_object().unwrap().len(),
265            1,
266            "only `name` should be present: {json}"
267        );
268    }
269
270    #[test]
271    fn tool_pre_invoke_round_trips() {
272        let payload = ToolPreInvokePayload {
273            name: "search".into(),
274            args: Some(HashMap::from([("q".into(), serde_json::json!(7))])),
275            headers: None,
276        };
277        let restored: ToolPreInvokePayload = serde_json::from_value(json_of(&payload)).unwrap();
278        assert_eq!(restored.name, "search");
279        assert_eq!(restored.args.unwrap()["q"], 7);
280    }
281
282    #[test]
283    fn tool_post_invoke_matches_the_python_field_shape() {
284        let json = json_of(&ToolPostInvokePayload {
285            name: "search".into(),
286            result: serde_json::json!({"hits": 3}),
287        });
288        assert_eq!(json["name"], "search");
289        assert_eq!(json["result"]["hits"], 3);
290    }
291
292    #[test]
293    fn prompt_payloads_use_prompt_id_not_name() {
294        // The prompt hooks key on `prompt_id`; using `name` here would fail
295        // Pydantic validation inside the worker.
296        let pre = json_of(&PromptPreFetchPayload {
297            prompt_id: "greeting".into(),
298            args: Some(HashMap::from([("lang".into(), "en".into())])),
299        });
300        assert_eq!(pre["prompt_id"], "greeting");
301        assert_eq!(pre["args"]["lang"], "en");
302        assert!(pre.get("name").is_none());
303
304        let post = json_of(&PromptPostFetchPayload {
305            prompt_id: "greeting".into(),
306            result: serde_json::json!({"messages": []}),
307        });
308        assert_eq!(post["prompt_id"], "greeting");
309        assert!(post["result"]["messages"].is_array());
310    }
311
312    #[test]
313    fn prompt_pre_fetch_args_are_strings() {
314        // dict[str, str] on the Python side, so a non-string value must not
315        // deserialize into it.
316        let parsed: Result<PromptPreFetchPayload, _> =
317            serde_json::from_value(serde_json::json!({ "prompt_id": "p", "args": {"n": 5} }));
318        assert!(
319            parsed.is_err(),
320            "prompt args are dict[str, str], not dict[str, Any]"
321        );
322    }
323
324    #[test]
325    fn resource_payloads_key_on_uri() {
326        let pre = json_of(&ResourcePreFetchPayload {
327            uri: "file:///tmp/x".into(),
328            metadata: Some(HashMap::from([("etag".into(), serde_json::json!("abc"))])),
329        });
330        assert_eq!(pre["uri"], "file:///tmp/x");
331        assert_eq!(pre["metadata"]["etag"], "abc");
332
333        let post = json_of(&ResourcePostFetchPayload {
334            uri: "file:///tmp/x".into(),
335            content: serde_json::json!("hello"),
336        });
337        assert_eq!(post["content"], "hello");
338    }
339
340    #[test]
341    fn identity_resolve_matches_the_python_field_shape() {
342        let json = json_of(&IdentityResolvePayload {
343            raw_token: "**********".into(),
344            source: "bearer".into(),
345            headers: HashMap::from([("Authorization".into(), "**********".into())]),
346            client_host: Some("10.0.0.1".into()),
347            client_port: Some(443),
348        });
349
350        assert_eq!(json["source"], "bearer");
351        assert_eq!(json["client_host"], "10.0.0.1");
352        assert_eq!(json["client_port"], 443);
353        assert!(
354            json.get("raw_token").is_some(),
355            "the field exists; its value is a placeholder"
356        );
357    }
358
359    #[test]
360    fn token_delegate_matches_the_python_field_shape_and_defaults() {
361        let parsed: TokenDelegatePayload =
362            serde_json::from_value(serde_json::json!({ "target_name": "billing-api" })).unwrap();
363
364        // Pydantic defaults these two; the host must agree or a minimal
365        // payload would deserialize with empty strings.
366        assert_eq!(parsed.target_type, "tool");
367        assert_eq!(parsed.auth_enforced_by, "caller");
368        assert!(parsed.required_permissions.is_empty());
369        assert!(parsed.bearer_token.is_none());
370    }
371
372    #[test]
373    fn token_delegate_carries_attenuation_config() {
374        let json = json_of(&TokenDelegatePayload {
375            target_name: "billing-api".into(),
376            route_attenuation: Some(AttenuationConfig {
377                capabilities: vec!["read".into()],
378                resource_template: Some("/v1/*".into()),
379                actions: vec!["GET".into()],
380                ttl_seconds: Some(300),
381            }),
382            ..Default::default()
383        });
384
385        assert_eq!(json["route_attenuation"]["capabilities"][0], "read");
386        assert_eq!(json["route_attenuation"]["ttl_seconds"], 300);
387    }
388
389    // --- redaction ----------------------------------------------------------
390
391    #[test]
392    fn identity_resolve_debug_hides_token_and_header_values() {
393        let payload = IdentityResolvePayload {
394            raw_token: "eyJhbGciOiJSUzI1NiJ9.SECRET-TOKEN-BYTES".into(),
395            source: "bearer".into(),
396            headers: HashMap::from([("Authorization".into(), "Bearer SECRET-TOKEN-BYTES".into())]),
397            client_host: None,
398            client_port: None,
399        };
400
401        let debug = format!("{payload:?}");
402        assert!(
403            !debug.contains("SECRET-TOKEN-BYTES"),
404            "token material leaked into Debug: {debug}"
405        );
406        // The header *name* is still useful for diagnosis.
407        assert!(debug.contains("Authorization"));
408        assert!(
409            debug.contains("bearer"),
410            "non-secret fields stay readable: {debug}"
411        );
412    }
413
414    #[test]
415    fn token_delegate_debug_hides_the_bearer_token() {
416        let payload = TokenDelegatePayload {
417            target_name: "billing-api".into(),
418            bearer_token: Some("MINTED-SECRET-BYTES".into()),
419            ..Default::default()
420        };
421
422        let debug = format!("{payload:?}");
423        assert!(
424            !debug.contains("MINTED-SECRET-BYTES"),
425            "delegated token leaked into Debug: {debug}"
426        );
427        // Presence is diagnosable without exposing the value.
428        assert!(debug.contains("billing-api"));
429        assert!(debug.contains(REDACTED));
430    }
431
432    #[test]
433    fn token_delegate_debug_distinguishes_absent_from_present() {
434        let absent = format!(
435            "{:?}",
436            TokenDelegatePayload {
437                target_name: "x".into(),
438                ..Default::default()
439            }
440        );
441        assert!(
442            absent.contains("None"),
443            "an absent token should read as None: {absent}"
444        );
445        assert!(!absent.contains(REDACTED));
446    }
447}