use std::collections::HashMap;
use serde::{Deserialize, Serialize};
const REDACTED: &str = "<redacted>";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolPreInvokePayload {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
praxis_policy_core::impl_plugin_payload!(ToolPreInvokePayload);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolPostInvokePayload {
pub name: String,
#[serde(default)]
pub result: serde_json::Value,
}
praxis_policy_core::impl_plugin_payload!(ToolPostInvokePayload);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PromptPreFetchPayload {
pub prompt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<HashMap<String, String>>,
}
praxis_policy_core::impl_plugin_payload!(PromptPreFetchPayload);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PromptPostFetchPayload {
pub prompt_id: String,
#[serde(default)]
pub result: serde_json::Value,
}
praxis_policy_core::impl_plugin_payload!(PromptPostFetchPayload);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourcePreFetchPayload {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
praxis_policy_core::impl_plugin_payload!(ResourcePreFetchPayload);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourcePostFetchPayload {
pub uri: String,
#[serde(default)]
pub content: serde_json::Value,
}
praxis_policy_core::impl_plugin_payload!(ResourcePostFetchPayload);
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct IdentityResolvePayload {
#[serde(default)]
pub raw_token: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_port: Option<u16>,
}
praxis_policy_core::impl_plugin_payload!(IdentityResolvePayload);
impl std::fmt::Debug for IdentityResolvePayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IdentityResolvePayload")
.field("raw_token", &REDACTED)
.field("source", &self.source)
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
.field("client_host", &self.client_host)
.field("client_port", &self.client_port)
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AttenuationConfig {
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_template: Option<String>,
#[serde(default)]
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<i64>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct TokenDelegatePayload {
pub target_name: String,
#[serde(default = "default_target_type")]
pub target_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_audience: Option<String>,
#[serde(default)]
pub required_permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_domain: Option<String>,
#[serde(default = "default_auth_enforced_by")]
pub auth_enforced_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_attenuation: Option<AttenuationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bearer_token: Option<String>,
}
praxis_policy_core::impl_plugin_payload!(TokenDelegatePayload);
fn default_target_type() -> String {
"tool".to_string()
}
fn default_auth_enforced_by() -> String {
"caller".to_string()
}
impl std::fmt::Debug for TokenDelegatePayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenDelegatePayload")
.field("target_name", &self.target_name)
.field("target_type", &self.target_type)
.field("target_audience", &self.target_audience)
.field("required_permissions", &self.required_permissions)
.field("trust_domain", &self.trust_domain)
.field("auth_enforced_by", &self.auth_enforced_by)
.field("route_attenuation", &self.route_attenuation)
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| REDACTED),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn json_of<T: Serialize>(value: &T) -> serde_json::Value {
serde_json::to_value(value).expect("payload serializes")
}
#[test]
fn tool_pre_invoke_matches_the_python_field_shape() {
let payload = ToolPreInvokePayload {
name: "search".into(),
args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
};
let json = json_of(&payload);
assert_eq!(json["name"], "search");
assert_eq!(json["args"]["q"], "rust");
assert_eq!(json["headers"]["X-Tenant"], "acme");
}
#[test]
fn tool_pre_invoke_omits_absent_optionals() {
let json = json_of(&ToolPreInvokePayload {
name: "search".into(),
..Default::default()
});
assert_eq!(
json.as_object().unwrap().len(),
1,
"only `name` should be present: {json}"
);
}
#[test]
fn tool_pre_invoke_round_trips() {
let payload = ToolPreInvokePayload {
name: "search".into(),
args: Some(HashMap::from([("q".into(), serde_json::json!(7))])),
headers: None,
};
let restored: ToolPreInvokePayload = serde_json::from_value(json_of(&payload)).unwrap();
assert_eq!(restored.name, "search");
assert_eq!(restored.args.unwrap()["q"], 7);
}
#[test]
fn tool_post_invoke_matches_the_python_field_shape() {
let json = json_of(&ToolPostInvokePayload {
name: "search".into(),
result: serde_json::json!({"hits": 3}),
});
assert_eq!(json["name"], "search");
assert_eq!(json["result"]["hits"], 3);
}
#[test]
fn prompt_payloads_use_prompt_id_not_name() {
let pre = json_of(&PromptPreFetchPayload {
prompt_id: "greeting".into(),
args: Some(HashMap::from([("lang".into(), "en".into())])),
});
assert_eq!(pre["prompt_id"], "greeting");
assert_eq!(pre["args"]["lang"], "en");
assert!(pre.get("name").is_none());
let post = json_of(&PromptPostFetchPayload {
prompt_id: "greeting".into(),
result: serde_json::json!({"messages": []}),
});
assert_eq!(post["prompt_id"], "greeting");
assert!(post["result"]["messages"].is_array());
}
#[test]
fn prompt_pre_fetch_args_are_strings() {
let parsed: Result<PromptPreFetchPayload, _> =
serde_json::from_value(serde_json::json!({ "prompt_id": "p", "args": {"n": 5} }));
assert!(
parsed.is_err(),
"prompt args are dict[str, str], not dict[str, Any]"
);
}
#[test]
fn resource_payloads_key_on_uri() {
let pre = json_of(&ResourcePreFetchPayload {
uri: "file:///tmp/x".into(),
metadata: Some(HashMap::from([("etag".into(), serde_json::json!("abc"))])),
});
assert_eq!(pre["uri"], "file:///tmp/x");
assert_eq!(pre["metadata"]["etag"], "abc");
let post = json_of(&ResourcePostFetchPayload {
uri: "file:///tmp/x".into(),
content: serde_json::json!("hello"),
});
assert_eq!(post["content"], "hello");
}
#[test]
fn identity_resolve_matches_the_python_field_shape() {
let json = json_of(&IdentityResolvePayload {
raw_token: "**********".into(),
source: "bearer".into(),
headers: HashMap::from([("Authorization".into(), "**********".into())]),
client_host: Some("10.0.0.1".into()),
client_port: Some(443),
});
assert_eq!(json["source"], "bearer");
assert_eq!(json["client_host"], "10.0.0.1");
assert_eq!(json["client_port"], 443);
assert!(
json.get("raw_token").is_some(),
"the field exists; its value is a placeholder"
);
}
#[test]
fn token_delegate_matches_the_python_field_shape_and_defaults() {
let parsed: TokenDelegatePayload =
serde_json::from_value(serde_json::json!({ "target_name": "billing-api" })).unwrap();
assert_eq!(parsed.target_type, "tool");
assert_eq!(parsed.auth_enforced_by, "caller");
assert!(parsed.required_permissions.is_empty());
assert!(parsed.bearer_token.is_none());
}
#[test]
fn token_delegate_carries_attenuation_config() {
let json = json_of(&TokenDelegatePayload {
target_name: "billing-api".into(),
route_attenuation: Some(AttenuationConfig {
capabilities: vec!["read".into()],
resource_template: Some("/v1/*".into()),
actions: vec!["GET".into()],
ttl_seconds: Some(300),
}),
..Default::default()
});
assert_eq!(json["route_attenuation"]["capabilities"][0], "read");
assert_eq!(json["route_attenuation"]["ttl_seconds"], 300);
}
#[test]
fn identity_resolve_debug_hides_token_and_header_values() {
let payload = IdentityResolvePayload {
raw_token: "eyJhbGciOiJSUzI1NiJ9.SECRET-TOKEN-BYTES".into(),
source: "bearer".into(),
headers: HashMap::from([("Authorization".into(), "Bearer SECRET-TOKEN-BYTES".into())]),
client_host: None,
client_port: None,
};
let debug = format!("{payload:?}");
assert!(
!debug.contains("SECRET-TOKEN-BYTES"),
"token material leaked into Debug: {debug}"
);
assert!(debug.contains("Authorization"));
assert!(
debug.contains("bearer"),
"non-secret fields stay readable: {debug}"
);
}
#[test]
fn token_delegate_debug_hides_the_bearer_token() {
let payload = TokenDelegatePayload {
target_name: "billing-api".into(),
bearer_token: Some("MINTED-SECRET-BYTES".into()),
..Default::default()
};
let debug = format!("{payload:?}");
assert!(
!debug.contains("MINTED-SECRET-BYTES"),
"delegated token leaked into Debug: {debug}"
);
assert!(debug.contains("billing-api"));
assert!(debug.contains(REDACTED));
}
#[test]
fn token_delegate_debug_distinguishes_absent_from_present() {
let absent = format!(
"{:?}",
TokenDelegatePayload {
target_name: "x".into(),
..Default::default()
}
);
assert!(
absent.contains("None"),
"an absent token should read as None: {absent}"
);
assert!(!absent.contains(REDACTED));
}
}