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}
44ppe::praxis_policy_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}
56ppe::praxis_policy_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}
69ppe::praxis_policy_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}
79ppe::praxis_policy_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}
89ppe::praxis_policy_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}
99ppe::praxis_policy_core::impl_plugin_payload!(ResourcePostFetchPayload);
100
101#[derive(Clone, Default, Serialize, Deserialize)]
112pub struct IdentityResolvePayload {
113 #[serde(default)]
116 pub raw_token: String,
117
118 #[serde(default)]
120 pub source: String,
121
122 #[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 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#[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#[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 #[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 .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 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 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 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 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 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 #[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 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 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}