use praxis_policy_core::cmf::MessagePayload;
use praxis_policy_core::cmf::constants::{
HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
};
use praxis_policy_core::delegation::{DelegationPayload, HOOK_TOKEN_DELEGATE};
use praxis_policy_core::error::PluginViolation;
use praxis_policy_core::executor::ErasedResultFields;
use praxis_policy_core::hooks::payload::PluginPayload;
use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::HostError;
use crate::legacy::payloads::{
IdentityResolvePayload, PromptPostFetchPayload, PromptPreFetchPayload,
ResourcePostFetchPayload, ResourcePreFetchPayload, TokenDelegatePayload, ToolPostInvokePayload,
ToolPreInvokePayload,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenericPayload {
pub value: Value,
}
praxis_policy_core::impl_plugin_payload!(GenericPayload);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadKind {
CmfMessage,
ToolPreInvoke,
ToolPostInvoke,
PromptPreFetch,
PromptPostFetch,
ResourcePreFetch,
ResourcePostFetch,
IdentityResolve,
TokenDelegate,
Generic,
}
struct HookRoute {
dispatched: &'static str,
kind: PayloadKind,
worker: &'static str,
}
const HOOK_ROUTES: &[HookRoute] = &[
route(
HOOK_CMF_TOOL_PRE_INVOKE,
PayloadKind::CmfMessage,
HOOK_CMF_TOOL_PRE_INVOKE,
),
route(
HOOK_CMF_TOOL_POST_INVOKE,
PayloadKind::CmfMessage,
HOOK_CMF_TOOL_POST_INVOKE,
),
route(
HOOK_CMF_LLM_INPUT,
PayloadKind::CmfMessage,
HOOK_CMF_LLM_INPUT,
),
route(
HOOK_CMF_LLM_OUTPUT,
PayloadKind::CmfMessage,
HOOK_CMF_LLM_OUTPUT,
),
route(
HOOK_CMF_PROMPT_PRE_INVOKE,
PayloadKind::CmfMessage,
"cmf.prompt_pre_fetch",
),
route(
HOOK_CMF_PROMPT_POST_INVOKE,
PayloadKind::CmfMessage,
"cmf.prompt_post_fetch",
),
route(
HOOK_CMF_RESOURCE_PRE_FETCH,
PayloadKind::CmfMessage,
HOOK_CMF_RESOURCE_PRE_FETCH,
),
route(
HOOK_CMF_RESOURCE_POST_FETCH,
PayloadKind::CmfMessage,
HOOK_CMF_RESOURCE_POST_FETCH,
),
route(
HOOK_IDENTITY_RESOLVE,
PayloadKind::IdentityResolve,
"identity_resolve",
),
route(
HOOK_TOKEN_DELEGATE,
PayloadKind::TokenDelegate,
"token_delegate",
),
route(
"tool_pre_invoke",
PayloadKind::ToolPreInvoke,
"tool_pre_invoke",
),
route(
"tool_post_invoke",
PayloadKind::ToolPostInvoke,
"tool_post_invoke",
),
route(
"prompt_pre_fetch",
PayloadKind::PromptPreFetch,
"prompt_pre_fetch",
),
route(
"prompt_post_fetch",
PayloadKind::PromptPostFetch,
"prompt_post_fetch",
),
route(
"resource_pre_fetch",
PayloadKind::ResourcePreFetch,
"resource_pre_fetch",
),
route(
"resource_post_fetch",
PayloadKind::ResourcePostFetch,
"resource_post_fetch",
),
];
const fn route(dispatched: &'static str, kind: PayloadKind, worker: &'static str) -> HookRoute {
HookRoute {
dispatched,
kind,
worker,
}
}
fn lookup_route(hook_name: &str) -> Option<&'static HookRoute> {
HOOK_ROUTES.iter().find(|r| r.dispatched == hook_name)
}
pub fn payload_kind_for_hook(hook_name: &str) -> PayloadKind {
lookup_route(hook_name).map_or(PayloadKind::Generic, |r| r.kind)
}
pub fn worker_hook_name(hook_name: &str) -> &str {
lookup_route(hook_name).map_or(hook_name, |r| r.worker)
}
pub fn is_credential_hook(hook_name: &str) -> bool {
matches!(
payload_kind_for_hook(hook_name),
PayloadKind::IdentityResolve | PayloadKind::TokenDelegate
)
}
pub fn serialize_payload(payload: &dyn PluginPayload) -> Result<Value, HostError> {
let any = payload.as_any();
macro_rules! try_downcast {
($($ty:ty),+ $(,)?) => {
$(
if let Some(concrete) = any.downcast_ref::<$ty>() {
return serde_json::to_value(concrete).map_err(|e| HostError::Protocol {
message: format!(
"could not serialize a {} payload: {e}",
stringify!($ty)
),
});
}
)+
};
}
if let Some(identity) = any.downcast_ref::<IdentityPayload>() {
return crate::credential_payloads::identity_to_worker(identity);
}
if let Some(delegation) = any.downcast_ref::<DelegationPayload>() {
return crate::credential_payloads::delegation_to_worker(delegation);
}
try_downcast!(
MessagePayload,
ToolPreInvokePayload,
ToolPostInvokePayload,
PromptPreFetchPayload,
PromptPostFetchPayload,
ResourcePreFetchPayload,
ResourcePostFetchPayload,
IdentityResolvePayload,
TokenDelegatePayload,
);
if let Some(generic) = any.downcast_ref::<GenericPayload>() {
return Ok(generic.value.clone());
}
Err(HostError::Protocol {
message: "payload type is not one this host can forward to a Python worker \
(expected a CMF message payload, a PPE identity or delegation payload, a legacy typed \
payload, or the generic payload)"
.into(),
})
}
pub fn deserialize_payload(
hook_name: &str,
value: Value,
) -> Result<Box<dyn PluginPayload>, HostError> {
fn parse<T>(value: Value, hook_name: &str) -> Result<Box<dyn PluginPayload>, HostError>
where
T: PluginPayload + serde::de::DeserializeOwned,
{
serde_json::from_value::<T>(value)
.map(|p| Box::new(p) as Box<dyn PluginPayload>)
.map_err(|e| HostError::Protocol {
message: format!(
"worker returned a modified payload that does not match hook '{hook_name}': {e}"
),
})
}
match payload_kind_for_hook(hook_name) {
PayloadKind::CmfMessage => parse::<MessagePayload>(value, hook_name),
PayloadKind::ToolPreInvoke => parse::<ToolPreInvokePayload>(value, hook_name),
PayloadKind::ToolPostInvoke => parse::<ToolPostInvokePayload>(value, hook_name),
PayloadKind::PromptPreFetch => parse::<PromptPreFetchPayload>(value, hook_name),
PayloadKind::PromptPostFetch => parse::<PromptPostFetchPayload>(value, hook_name),
PayloadKind::ResourcePreFetch => parse::<ResourcePreFetchPayload>(value, hook_name),
PayloadKind::ResourcePostFetch => parse::<ResourcePostFetchPayload>(value, hook_name),
PayloadKind::IdentityResolve => parse::<IdentityResolvePayload>(value, hook_name),
PayloadKind::TokenDelegate => parse::<TokenDelegatePayload>(value, hook_name),
PayloadKind::Generic => Ok(Box::new(GenericPayload { value })),
}
}
pub fn response_to_result(
hook_name: &str,
response: Value,
inbound: &praxis_policy_core::extensions::Extensions,
) -> Result<ErasedResultFields, HostError> {
let continue_processing = response
.get("continue_processing")
.and_then(Value::as_bool)
.unwrap_or(true);
let violation = match response.get("violation") {
Some(Value::Null) | None => None,
Some(raw) => Some(parse_violation(raw.clone())?),
};
let modified_payload = match response.get("modified_payload") {
Some(Value::Null) | None => None,
Some(raw) => Some(deserialize_payload(hook_name, raw.clone())?),
};
let modified_extensions = match response.get(crate::extensions::MODIFIED_EXTENSIONS_FIELD) {
Some(Value::Null) | None => None,
Some(raw) => crate::extensions::owned_from_returned_slot(raw, inbound)?,
};
Ok(ErasedResultFields {
continue_processing,
modified_payload,
modified_extensions,
violation,
})
}
fn parse_violation(raw: Value) -> Result<PluginViolation, HostError> {
let obj = raw.as_object().ok_or_else(|| HostError::Protocol {
message: "worker returned a violation that is not a JSON object".into(),
})?;
let code = obj.get("code").and_then(Value::as_str).unwrap_or_default();
let reason = obj
.get("reason")
.and_then(Value::as_str)
.unwrap_or_default();
let mut violation = PluginViolation::new(code, reason);
violation.description = obj
.get("description")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(details) = obj.get("details").and_then(Value::as_object) {
violation.details = details.clone().into_iter().collect();
}
violation.proto_error_code = obj
.get("mcp_error_code")
.or_else(|| obj.get("proto_error_code"))
.and_then(Value::as_i64);
Ok(violation)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn no_inbound() -> praxis_policy_core::extensions::Extensions {
praxis_policy_core::extensions::Extensions::default()
}
#[test]
fn every_ppe_cmf_hook_routes_to_the_message_payload() {
for hook in [
"cmf.tool_pre_invoke",
"cmf.tool_post_invoke",
"cmf.llm_input",
"cmf.llm_output",
"cmf.prompt_pre_invoke",
"cmf.prompt_post_invoke",
"cmf.resource_pre_fetch",
"cmf.resource_post_fetch",
] {
assert_eq!(
payload_kind_for_hook(hook),
PayloadKind::CmfMessage,
"hook '{hook}' resolved wrongly"
);
}
}
#[test]
fn the_table_covers_every_cmf_hook_ppe_dispatches() {
for hook in praxis_policy_core::hooks::builtin_hook_types() {
let name = hook.as_str();
if name.starts_with("cmf.") {
assert_eq!(
payload_kind_for_hook(name),
PayloadKind::CmfMessage,
"PPE dispatches '{name}' but the hook table does not route it"
);
}
}
}
#[test]
fn identity_and_delegation_route_under_ppe_names() {
assert_eq!(
payload_kind_for_hook("identity.resolve"),
PayloadKind::IdentityResolve
);
assert_eq!(
payload_kind_for_hook("token.delegate"),
PayloadKind::TokenDelegate
);
}
#[test]
fn host_registered_legacy_hooks_keep_their_typed_payloads() {
let expected = [
("tool_pre_invoke", PayloadKind::ToolPreInvoke),
("tool_post_invoke", PayloadKind::ToolPostInvoke),
("prompt_pre_fetch", PayloadKind::PromptPreFetch),
("prompt_post_fetch", PayloadKind::PromptPostFetch),
("resource_pre_fetch", PayloadKind::ResourcePreFetch),
("resource_post_fetch", PayloadKind::ResourcePostFetch),
];
for (hook, kind) in expected {
assert_eq!(
payload_kind_for_hook(hook),
kind,
"hook '{hook}' resolved wrongly"
);
}
let kinds: std::collections::HashSet<_> =
expected.iter().map(|(_, k)| format!("{k:?}")).collect();
assert_eq!(
kinds.len(),
6,
"each legacy hook needs its own payload kind"
);
}
#[test]
fn a_hook_outside_the_table_falls_back_to_the_generic_payload() {
for hook in [
"some_custom_hook",
"",
"cmf.some_future_hook",
"http.request",
"elicit",
"identity_resolve",
"token_delegate",
"cmf.prompt_pre_fetch",
] {
assert_eq!(
payload_kind_for_hook(hook),
PayloadKind::Generic,
"'{hook}' must not get a typed payload"
);
assert_eq!(
worker_hook_name(hook),
hook,
"'{hook}' must reach the worker unchanged"
);
}
}
#[test]
fn only_identity_and_delegation_are_credential_hooks() {
assert!(is_credential_hook("identity.resolve"));
assert!(is_credential_hook("token.delegate"));
for hook in [
"identity_resolve",
"token_delegate",
"tool_pre_invoke",
"tool_post_invoke",
"prompt_pre_fetch",
"resource_pre_fetch",
"cmf.tool_pre_invoke",
"cmf.prompt_pre_invoke",
"http.request",
"unknown",
] {
assert!(
!is_credential_hook(hook),
"'{hook}' must not receive credentials"
);
}
}
#[test]
fn ppe_names_translate_to_the_worker_spelling() {
let expected = [
("identity.resolve", "identity_resolve"),
("token.delegate", "token_delegate"),
("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"),
("cmf.prompt_post_invoke", "cmf.prompt_post_fetch"),
("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"),
("cmf.tool_post_invoke", "cmf.tool_post_invoke"),
("cmf.llm_input", "cmf.llm_input"),
("cmf.llm_output", "cmf.llm_output"),
("cmf.resource_pre_fetch", "cmf.resource_pre_fetch"),
("cmf.resource_post_fetch", "cmf.resource_post_fetch"),
("tool_pre_invoke", "tool_pre_invoke"),
];
for (dispatched, worker) in expected {
assert_eq!(worker_hook_name(dispatched), worker, "{dispatched}");
}
}
#[test]
fn identity_and_delegation_modified_payloads_keep_their_types() {
let boxed = deserialize_payload(
"identity.resolve",
serde_json::json!({ "raw_token": "**********", "source": "bearer" }),
)
.expect("parses");
assert!(
boxed
.as_any()
.downcast_ref::<IdentityResolvePayload>()
.is_some(),
"identity.resolve must come back as the identity payload"
);
let boxed = deserialize_payload(
"token.delegate",
serde_json::json!({ "target_name": "billing-api" }),
)
.expect("parses");
assert!(
boxed
.as_any()
.downcast_ref::<TokenDelegatePayload>()
.is_some(),
"token.delegate must come back as the delegation payload"
);
}
#[test]
fn a_tool_pre_invoke_payload_serializes_to_the_worker_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 = serialize_payload(&payload).expect("serializes");
assert_eq!(json["name"], "search");
assert_eq!(json["args"]["q"], "rust");
assert_eq!(json["headers"]["X-Tenant"], "acme");
}
#[test]
fn the_generic_payload_serializes_to_its_inner_value() {
let payload = GenericPayload {
value: serde_json::json!({"anything": [1, 2, 3]}),
};
let json = serialize_payload(&payload).expect("serializes");
assert_eq!(json["anything"][2], 3);
assert!(
json.get("value").is_none(),
"the wrapper must not appear on the wire"
);
}
#[test]
fn a_cmf_message_payload_serializes_as_itself() {
use praxis_policy_core::cmf::{Message, Role};
let payload = MessagePayload {
message: Message::text(Role::User, "what is the weather?"),
};
let json = serialize_payload(&payload).expect("serializes");
assert_eq!(json["message"]["role"], "user");
}
#[test]
fn an_unforwardable_payload_type_errors_rather_than_sending_null() {
#[derive(Debug, Clone)]
struct ForeignPayload;
praxis_policy_core::impl_plugin_payload!(ForeignPayload);
let err =
serialize_payload(&ForeignPayload).expect_err("an unknown type cannot be forwarded");
assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
}
#[test]
fn a_modified_payload_deserializes_into_the_hooks_typed_payload() {
let boxed =
deserialize_payload("tool_pre_invoke", serde_json::json!({ "name": "redacted" }))
.expect("parses");
let typed = boxed
.as_any()
.downcast_ref::<ToolPreInvokePayload>()
.expect("must come back as the tool payload, or the executor's downcast fails");
assert_eq!(typed.name, "redacted");
}
#[test]
fn a_cmf_modified_payload_deserializes_as_a_message_payload() {
use praxis_policy_core::cmf::{Message, Role};
let original = MessagePayload {
message: Message::text(Role::Assistant, "redacted"),
};
let json = serde_json::to_value(&original).unwrap();
let boxed = deserialize_payload("cmf.tool_pre_invoke", json).expect("parses");
let typed = boxed
.as_any()
.downcast_ref::<MessagePayload>()
.expect("a cmf hook's modified payload must come back as a MessagePayload");
assert_eq!(typed.message.role, Role::Assistant);
}
#[test]
fn an_unknown_hooks_modified_payload_becomes_a_generic_payload() {
let boxed =
deserialize_payload("mystery_hook", serde_json::json!({"x": 1})).expect("parses");
let generic = boxed
.as_any()
.downcast_ref::<GenericPayload>()
.expect("generic");
assert_eq!(generic.value["x"], 1);
}
#[test]
fn a_mismatched_modified_payload_errors_and_names_the_hook() {
let err = deserialize_payload("tool_pre_invoke", serde_json::json!({ "unexpected": true }))
.expect_err("a payload missing required fields must be rejected");
assert!(err.to_string().contains("tool_pre_invoke"), "{err}");
}
#[test]
fn an_allow_response_becomes_an_allow_result() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": true }),
&no_inbound(),
)
.expect("converts");
assert!(fields.continue_processing);
assert!(fields.violation.is_none());
assert!(fields.modified_payload.is_none());
assert!(fields.modified_extensions.is_none());
}
#[test]
fn a_response_without_continue_processing_defaults_to_allow() {
let fields = response_to_result("tool_pre_invoke", serde_json::json!({}), &no_inbound())
.expect("converts");
assert!(fields.continue_processing);
}
#[test]
fn a_deny_response_carries_its_violation() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": false,
"violation": {
"code": "PII_DETECTED",
"reason": "email address in args",
"description": "matched the email pattern",
"details": {"field": "q"},
"mcp_error_code": -32603
}
}),
&no_inbound(),
)
.expect("converts");
assert!(!fields.continue_processing);
let violation = fields.violation.expect("a deny must carry its violation");
assert_eq!(violation.code, "PII_DETECTED");
assert_eq!(violation.reason, "email address in args");
assert_eq!(
violation.description.as_deref(),
Some("matched the email pattern")
);
assert_eq!(violation.details["field"], "q");
assert_eq!(
violation.proto_error_code,
Some(-32603),
"the Python mcp_error_code maps onto the Rust proto_error_code"
);
}
#[test]
fn a_violation_with_only_a_reason_still_denies() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": false, "violation": { "reason": "nope" } }),
&no_inbound(),
)
.expect("a sparse violation is still a violation");
assert!(!fields.continue_processing);
let violation = fields.violation.unwrap();
assert_eq!(violation.reason, "nope");
assert_eq!(violation.code, "");
}
#[test]
fn a_modified_payload_survives_the_round_trip() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_payload": { "name": "search", "args": {"q": "[REDACTED]"} }
}),
&no_inbound(),
)
.expect("converts");
let payload = fields
.modified_payload
.expect("the modification must survive");
let typed = payload
.as_any()
.downcast_ref::<ToolPreInvokePayload>()
.unwrap();
assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
}
#[test]
fn modified_extensions_survive_the_round_trip() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { "custom": { "seen_by": "py-plugin" } }
}),
&no_inbound(),
)
.expect("converts");
let extensions = fields.modified_extensions.expect("extensions must survive");
assert_eq!(
extensions.custom.expect("custom slot")["seen_by"],
"py-plugin"
);
}
#[test]
fn a_gated_slot_write_without_the_capability_is_dropped() {
for slot in ["http", "security", "delegation"] {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { slot: {"labels": ["PII"]} }
}),
&no_inbound(),
)
.expect("an unauthorized gated write is dropped, not a protocol error");
assert!(
fields.modified_extensions.is_none(),
"the '{slot}' write had no token behind it, so nothing should merge"
);
}
}
#[test]
fn an_extensions_object_with_only_gated_nulls_is_no_modification() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { "http": null, "security": null, "custom": null }
}),
&no_inbound(),
)
.expect("all-null slots are not a write");
assert!(fields.modified_extensions.is_none());
}
#[test]
fn explicit_nulls_are_treated_as_absent() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_payload": null,
"modified_extensions": null,
"violation": null
}),
&no_inbound(),
)
.expect("nulls are absent, not malformed");
assert!(fields.modified_payload.is_none());
assert!(fields.modified_extensions.is_none());
assert!(fields.violation.is_none());
}
#[test]
fn a_malformed_modified_payload_surfaces_as_a_protocol_error() {
let Err(err) = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": true, "modified_payload": "not an object" }),
&no_inbound(),
) else {
panic!("a payload that cannot be rebuilt must not be silently dropped");
};
assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
}
}