1use std::collections::HashMap;
24
25use serde::{Deserialize, Serialize};
26
27const REDACTED: &str = "<redacted>";
29
30#[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}
44cpex_core::impl_plugin_payload!(ToolPreInvokePayload);
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ToolPostInvokePayload {
51 pub name: String,
52
53 #[serde(default)]
54 pub result: serde_json::Value,
55}
56cpex_core::impl_plugin_payload!(ToolPostInvokePayload);
57
58#[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}
69cpex_core::impl_plugin_payload!(PromptPreFetchPayload);
70
71#[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}
79cpex_core::impl_plugin_payload!(PromptPostFetchPayload);
80
81#[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}
89cpex_core::impl_plugin_payload!(ResourcePreFetchPayload);
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct ResourcePostFetchPayload {
94 pub uri: String,
95
96 #[serde(default)]
97 pub content: serde_json::Value,
98}
99cpex_core::impl_plugin_payload!(ResourcePostFetchPayload);
100
101#[derive(Clone, Default, Serialize, Deserialize)]
111pub struct IdentityResolvePayload {
112 #[serde(default)]
115 pub raw_token: String,
116
117 #[serde(default)]
119 pub source: String,
120
121 #[serde(default)]
125 pub headers: HashMap<String, String>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub client_host: Option<String>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub client_port: Option<u16>,
132}
133cpex_core::impl_plugin_payload!(IdentityResolvePayload);
134
135impl std::fmt::Debug for IdentityResolvePayload {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("IdentityResolvePayload")
141 .field("raw_token", &REDACTED)
142 .field("source", &self.source)
143 .field("header_names", &self.headers.keys().collect::<Vec<_>>())
144 .field("client_host", &self.client_host)
145 .field("client_port", &self.client_port)
146 .finish()
147 }
148}
149
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152pub struct AttenuationConfig {
153 #[serde(default)]
154 pub capabilities: Vec<String>,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub resource_template: Option<String>,
158
159 #[serde(default)]
160 pub actions: Vec<String>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub ttl_seconds: Option<i64>,
164}
165
166#[derive(Clone, Default, Serialize, Deserialize)]
174pub struct TokenDelegatePayload {
175 pub target_name: String,
176
177 #[serde(default = "default_target_type")]
178 pub target_type: String,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub target_audience: Option<String>,
182
183 #[serde(default)]
184 pub required_permissions: Vec<String>,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub trust_domain: Option<String>,
188
189 #[serde(default = "default_auth_enforced_by")]
190 pub auth_enforced_by: String,
191
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub route_attenuation: Option<AttenuationConfig>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub bearer_token: Option<String>,
198}
199cpex_core::impl_plugin_payload!(TokenDelegatePayload);
200
201fn default_target_type() -> String {
202 "tool".to_string()
203}
204
205fn default_auth_enforced_by() -> String {
206 "caller".to_string()
207}
208
209impl std::fmt::Debug for TokenDelegatePayload {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("TokenDelegatePayload")
212 .field("target_name", &self.target_name)
213 .field("target_type", &self.target_type)
214 .field("target_audience", &self.target_audience)
215 .field("required_permissions", &self.required_permissions)
216 .field("trust_domain", &self.trust_domain)
217 .field("auth_enforced_by", &self.auth_enforced_by)
218 .field("route_attenuation", &self.route_attenuation)
219 .field(
221 "bearer_token",
222 &self.bearer_token.as_ref().map(|_| REDACTED),
223 )
224 .finish()
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn json_of<T: Serialize>(value: &T) -> serde_json::Value {
235 serde_json::to_value(value).expect("payload serializes")
236 }
237
238 #[test]
239 fn tool_pre_invoke_matches_the_python_field_shape() {
240 let payload = ToolPreInvokePayload {
241 name: "search".into(),
242 args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
243 headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
244 };
245 let json = json_of(&payload);
246
247 assert_eq!(json["name"], "search");
248 assert_eq!(json["args"]["q"], "rust");
249 assert_eq!(json["headers"]["X-Tenant"], "acme");
250 }
251
252 #[test]
253 fn tool_pre_invoke_omits_absent_optionals() {
254 let json = json_of(&ToolPreInvokePayload {
258 name: "search".into(),
259 ..Default::default()
260 });
261 assert_eq!(
262 json.as_object().unwrap().len(),
263 1,
264 "only `name` should be present: {json}"
265 );
266 }
267
268 #[test]
269 fn tool_pre_invoke_round_trips() {
270 let payload = ToolPreInvokePayload {
271 name: "search".into(),
272 args: Some(HashMap::from([("q".into(), serde_json::json!(7))])),
273 headers: None,
274 };
275 let restored: ToolPreInvokePayload = serde_json::from_value(json_of(&payload)).unwrap();
276 assert_eq!(restored.name, "search");
277 assert_eq!(restored.args.unwrap()["q"], 7);
278 }
279
280 #[test]
281 fn tool_post_invoke_matches_the_python_field_shape() {
282 let json = json_of(&ToolPostInvokePayload {
283 name: "search".into(),
284 result: serde_json::json!({"hits": 3}),
285 });
286 assert_eq!(json["name"], "search");
287 assert_eq!(json["result"]["hits"], 3);
288 }
289
290 #[test]
291 fn prompt_payloads_use_prompt_id_not_name() {
292 let pre = json_of(&PromptPreFetchPayload {
295 prompt_id: "greeting".into(),
296 args: Some(HashMap::from([("lang".into(), "en".into())])),
297 });
298 assert_eq!(pre["prompt_id"], "greeting");
299 assert_eq!(pre["args"]["lang"], "en");
300 assert!(pre.get("name").is_none());
301
302 let post = json_of(&PromptPostFetchPayload {
303 prompt_id: "greeting".into(),
304 result: serde_json::json!({"messages": []}),
305 });
306 assert_eq!(post["prompt_id"], "greeting");
307 assert!(post["result"]["messages"].is_array());
308 }
309
310 #[test]
311 fn prompt_pre_fetch_args_are_strings() {
312 let parsed: Result<PromptPreFetchPayload, _> =
315 serde_json::from_value(serde_json::json!({ "prompt_id": "p", "args": {"n": 5} }));
316 assert!(
317 parsed.is_err(),
318 "prompt args are dict[str, str], not dict[str, Any]"
319 );
320 }
321
322 #[test]
323 fn resource_payloads_key_on_uri() {
324 let pre = json_of(&ResourcePreFetchPayload {
325 uri: "file:///tmp/x".into(),
326 metadata: Some(HashMap::from([("etag".into(), serde_json::json!("abc"))])),
327 });
328 assert_eq!(pre["uri"], "file:///tmp/x");
329 assert_eq!(pre["metadata"]["etag"], "abc");
330
331 let post = json_of(&ResourcePostFetchPayload {
332 uri: "file:///tmp/x".into(),
333 content: serde_json::json!("hello"),
334 });
335 assert_eq!(post["content"], "hello");
336 }
337
338 #[test]
339 fn identity_resolve_matches_the_python_field_shape() {
340 let json = json_of(&IdentityResolvePayload {
341 raw_token: "**********".into(),
342 source: "bearer".into(),
343 headers: HashMap::from([("Authorization".into(), "**********".into())]),
344 client_host: Some("10.0.0.1".into()),
345 client_port: Some(443),
346 });
347
348 assert_eq!(json["source"], "bearer");
349 assert_eq!(json["client_host"], "10.0.0.1");
350 assert_eq!(json["client_port"], 443);
351 assert!(
352 json.get("raw_token").is_some(),
353 "the field exists; its value is a placeholder"
354 );
355 }
356
357 #[test]
358 fn token_delegate_matches_the_python_field_shape_and_defaults() {
359 let parsed: TokenDelegatePayload =
360 serde_json::from_value(serde_json::json!({ "target_name": "billing-api" })).unwrap();
361
362 assert_eq!(parsed.target_type, "tool");
365 assert_eq!(parsed.auth_enforced_by, "caller");
366 assert!(parsed.required_permissions.is_empty());
367 assert!(parsed.bearer_token.is_none());
368 }
369
370 #[test]
371 fn token_delegate_carries_attenuation_config() {
372 let json = json_of(&TokenDelegatePayload {
373 target_name: "billing-api".into(),
374 route_attenuation: Some(AttenuationConfig {
375 capabilities: vec!["read".into()],
376 resource_template: Some("/v1/*".into()),
377 actions: vec!["GET".into()],
378 ttl_seconds: Some(300),
379 }),
380 ..Default::default()
381 });
382
383 assert_eq!(json["route_attenuation"]["capabilities"][0], "read");
384 assert_eq!(json["route_attenuation"]["ttl_seconds"], 300);
385 }
386
387 #[test]
390 fn identity_resolve_debug_hides_token_and_header_values() {
391 let payload = IdentityResolvePayload {
392 raw_token: "eyJhbGciOiJSUzI1NiJ9.SECRET-TOKEN-BYTES".into(),
393 source: "bearer".into(),
394 headers: HashMap::from([("Authorization".into(), "Bearer SECRET-TOKEN-BYTES".into())]),
395 client_host: None,
396 client_port: None,
397 };
398
399 let debug = format!("{payload:?}");
400 assert!(
401 !debug.contains("SECRET-TOKEN-BYTES"),
402 "token material leaked into Debug: {debug}"
403 );
404 assert!(debug.contains("Authorization"));
406 assert!(
407 debug.contains("bearer"),
408 "non-secret fields stay readable: {debug}"
409 );
410 }
411
412 #[test]
413 fn token_delegate_debug_hides_the_bearer_token() {
414 let payload = TokenDelegatePayload {
415 target_name: "billing-api".into(),
416 bearer_token: Some("MINTED-SECRET-BYTES".into()),
417 ..Default::default()
418 };
419
420 let debug = format!("{payload:?}");
421 assert!(
422 !debug.contains("MINTED-SECRET-BYTES"),
423 "delegated token leaked into Debug: {debug}"
424 );
425 assert!(debug.contains("billing-api"));
427 assert!(debug.contains(REDACTED));
428 }
429
430 #[test]
431 fn token_delegate_debug_distinguishes_absent_from_present() {
432 let absent = format!(
433 "{:?}",
434 TokenDelegatePayload {
435 target_name: "x".into(),
436 ..Default::default()
437 }
438 );
439 assert!(
440 absent.contains("None"),
441 "an absent token should read as None: {absent}"
442 );
443 assert!(!absent.contains(REDACTED));
444 }
445}