use std::sync::{Arc, Weak};
use async_trait::async_trait;
use serde_json::Value;
use cpex_core::cmf::MessagePayload;
use cpex_core::context::PluginContext;
use cpex_core::error::{PluginError, PluginViolation};
use cpex_core::executor::ErasedResultFields;
use cpex_core::extensions::Extensions;
use cpex_core::hooks::PluginPayload;
use cpex_core::manager::PluginManager;
use cpex_core::plugin::{Plugin, PluginConfig};
use cpex_core::registry::AnyHookHandler;
use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
use apl_cmf::{extract_args, extract_result, BagBuilder};
use apl_core::evaluator::Decision;
use apl_core::plugin_decl::PluginRegistry;
use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
use apl_core::rules::{CompiledRoute, DenyResponse};
use apl_core::step::PdpResolver;
use crate::cmf_invoker::CmfPluginInvoker;
use crate::delegation_invoker::DelegationPluginInvoker;
use crate::dispatch_plan::DispatchCache;
use crate::elicitation_invoker::ElicitationPluginInvoker;
use crate::pdp_router::PdpRouter;
use crate::session_store::SessionStore;
pub const ELICITATION_PENDING_CODE: i64 = -32120;
pub const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id";
pub const ELICITATION_APPROVED_CODE: i64 = -32121;
pub const ELICITATION_PEEK_HEADER: &str = "X-Policy-Elicitation-Peek";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Pre,
Post,
}
pub struct AplRouteHandler {
config: PluginConfig,
route: Arc<CompiledRoute>,
phase: Phase,
plugin_registry: Arc<PluginRegistry>,
dispatch_cache: Arc<DispatchCache>,
session_store: Arc<dyn SessionStore>,
manager: Weak<PluginManager>,
pdp: Arc<dyn PdpResolver>,
}
impl AplRouteHandler {
pub fn new(
config: PluginConfig,
route: Arc<CompiledRoute>,
phase: Phase,
plugin_registry: Arc<PluginRegistry>,
dispatch_cache: Arc<DispatchCache>,
session_store: Arc<dyn SessionStore>,
manager: Weak<PluginManager>,
) -> Self {
Self {
config,
route,
phase,
plugin_registry,
dispatch_cache,
session_store,
manager,
pdp: Arc::new(PdpRouter::new()),
}
}
pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
self.pdp = pdp;
self
}
pub fn with_pdp_router(
mut self,
resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
) -> Self {
let mut router = PdpRouter::new();
for r in resolvers {
router.register(r);
}
self.pdp = Arc::new(router);
self
}
}
#[async_trait]
impl Plugin for AplRouteHandler {
fn config(&self) -> &PluginConfig {
&self.config
}
}
#[async_trait]
impl AnyHookHandler for AplRouteHandler {
async fn invoke(
&self,
payload: &dyn PluginPayload,
extensions: &Extensions,
_ctx: &mut PluginContext,
) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
let msg_payload = payload
.as_any()
.downcast_ref::<MessagePayload>()
.ok_or_else(|| {
Box::new(PluginError::Config {
message: format!(
"AplRouteHandler '{}': payload was not MessagePayload",
self.route.route_key
),
})
})?;
let manager = self.manager.upgrade().ok_or_else(|| {
Box::new(PluginError::Config {
message: format!(
"AplRouteHandler '{}': PluginManager dropped before invoke",
self.route.route_key
),
})
})?;
let plan = self
.dispatch_cache
.get_or_build(&self.route, &self.plugin_registry, &manager)
.await;
let invoker = match CmfPluginInvoker::for_request(
Arc::clone(&manager),
extensions.clone(),
msg_payload.clone(),
plan,
Arc::clone(&self.session_store),
)
.await
{
Ok(inv) => Arc::new(inv),
Err(e) => {
tracing::error!(
alarm = "session_store_failure",
op = "load",
route = %self.route.route_key,
error = %e,
"session label load failed; failing request closed"
);
let mut v = PluginViolation::new(
"session.load_failed",
"session state could not be loaded",
);
decorate_denial_response(&mut v, self.route.response.as_ref());
return Ok(Box::new(ErasedResultFields {
continue_processing: false,
modified_payload: None,
modified_extensions: None,
violation: Some(v),
}));
},
};
let post_extensions = invoker.current_extensions().await;
let mut bag = BagBuilder::new()
.with_extensions(&post_extensions)
.with_route_key(&self.route.route_key)
.build();
if let Some(elicitation_id) = elicitation_id_from_headers(&post_extensions) {
bag.set(apl_core::step::elicitation_bag_keys::ID, elicitation_id);
}
let args_value = extract_args_from_message(&msg_payload.message);
let mut route_payload = match self.phase {
Phase::Pre => RoutePayload::new(args_value),
Phase::Post => {
let result_value = extract_result_from_message(&msg_payload.message);
RoutePayload::with_result(args_value, result_value)
},
};
extract_args(&route_payload.args, &mut bag);
if matches!(self.phase, Phase::Post) {
if let Some(result_value) = route_payload.result.as_ref() {
extract_result(result_value, &mut bag);
}
}
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&manager),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let elicitations = Arc::new(ElicitationPluginInvoker::new(
Arc::clone(&manager),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
let elicitations_dyn: Arc<dyn apl_core::step::ElicitationInvoker> = elicitations.clone();
let decision = match self.phase {
Phase::Pre => {
evaluate_pre(
&self.route,
&mut bag,
&mut route_payload,
&self.pdp,
&invoker_dyn,
&delegations_dyn,
&elicitations_dyn,
)
.await
},
Phase::Post => {
evaluate_post(
&self.route,
&mut bag,
&mut route_payload,
&self.pdp,
&invoker_dyn,
&delegations_dyn,
&elicitations_dyn,
)
.await
},
};
invoker.apply_session_taints(&decision.taints).await;
let persist_result = invoker.persist_session().await;
let final_payload = invoker.current_payload().await;
let final_extensions = invoker.current_extensions().await;
let pre_args = extract_args_from_message(&msg_payload.message);
let pre_result = match self.phase {
Phase::Pre => None,
Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
};
let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
let mut updated = final_payload.clone();
write_args_back_to_message(&mut updated.message, &route_payload.args);
Some(Box::new(updated) as Box<dyn PluginPayload>)
} else if matches!(self.phase, Phase::Post)
&& pre_result
.as_ref()
.zip(route_payload.result.as_ref())
.map(|(prev, current)| prev != current)
.unwrap_or(false)
{
let mut updated = final_payload.clone();
if let Some(result_value) = route_payload.result.as_ref() {
write_result_back_to_message(&mut updated.message, result_value);
}
Some(Box::new(updated) as Box<dyn PluginPayload>)
} else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
{
Some(Box::new(final_payload) as Box<dyn PluginPayload>)
} else {
None
};
let modified_extensions = if extensions_changed(extensions, &final_extensions) {
Some(final_extensions.cow_copy())
} else {
None
};
let pending_elicitation = decision.pending.clone();
let (mut continue_processing, mut violation) = match decision.decision {
Decision::Allow => (true, None),
Decision::Deny {
reason,
rule_source,
} => {
let code = if rule_source.is_empty() {
"policy.deny".to_string()
} else {
rule_source
};
let reason = reason.unwrap_or_else(|| "access denied".to_string());
let mut v = PluginViolation::new(code, reason);
decorate_denial_response(&mut v, self.route.response.as_ref());
(false, Some(v))
},
};
if let Some(p) = &pending_elicitation {
tracing::info!(
route = %self.route.route_key,
elicitation_id = %p.id,
plugin = %p.plugin_name,
"policy suspended on pending elicitation; emitting -32120 (retry)"
);
continue_processing = false;
violation = Some(pending_violation(p));
}
if continue_processing
&& elicitation_peek_from_headers(&post_extensions)
&& bag.get_string(apl_core::step::elicitation_bag_keys::OUTCOME) == Some("approved")
{
continue_processing = false;
violation = Some(approved_peek_violation(&bag));
}
if let Err(e) = persist_result {
tracing::error!(
alarm = "session_store_failure",
op = "append",
route = %self.route.route_key,
decision_was_allow = continue_processing,
error = %e,
"session label persist failed; failing request closed"
);
if continue_processing {
continue_processing = false;
let mut v = PluginViolation::new(
"session.persist_failed",
"session state could not be persisted",
);
decorate_denial_response(&mut v, self.route.response.as_ref());
violation = Some(v);
}
}
Ok(Box::new(ErasedResultFields {
continue_processing,
modified_payload,
modified_extensions,
violation,
}))
}
fn hook_type_name(&self) -> &'static str {
"cmf"
}
}
fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
let Some(resp) = response else {
return;
};
if let Some(status) = resp.status {
violation
.details
.insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
}
if let Some(body) = &resp.body {
violation
.details
.insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
}
if !resp.headers.is_empty() {
violation.details.insert(
DETAIL_HTTP_HEADERS.to_string(),
serde_json::json!(resp.headers),
);
}
}
fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
for part in msg.content.iter_mut() {
if let cpex_core::cmf::ContentPart::Text { text } = part {
*text = new_text.to_string();
return;
}
}
msg.content.push(cpex_core::cmf::ContentPart::Text {
text: new_text.to_string(),
});
}
fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
use cpex_core::cmf::ContentPart;
for part in &msg.content {
match part {
ContentPart::ToolCall { content } => {
return Value::Object(
content
.arguments
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
},
ContentPart::PromptRequest { content } => {
return Value::Object(
content
.arguments
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
},
_ => {},
}
}
Value::String(msg.get_text_content())
}
fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
use cpex_core::cmf::ContentPart;
for part in msg.content.iter_mut() {
match part {
ContentPart::ToolCall { content } => {
if let Some(obj) = args.as_object() {
content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
}
return;
},
ContentPart::PromptRequest { content } => {
if let Some(obj) = args.as_object() {
content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
}
return;
},
_ => {},
}
}
if let Some(text) = args.as_str() {
rewrite_message_text(msg, text);
}
}
fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
use cpex_core::cmf::ContentPart;
for part in &msg.content {
if let ContentPart::ToolResult { content } = part {
return content.content.clone();
}
}
Value::String(msg.get_text_content())
}
fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
use cpex_core::cmf::ContentPart;
for part in msg.content.iter_mut() {
if let ContentPart::ToolResult { content } = part {
content.content = result.clone();
return;
}
}
if let Some(text) = result.as_str() {
rewrite_message_text(msg, text);
}
}
fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
(Some(a), Some(b)) => !Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
(Some(a), Some(b)) => !Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let raw_creds_changed = match (
before.raw_credentials.as_ref(),
after.raw_credentials.as_ref(),
) {
(Some(a), Some(b)) => !Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
security_changed || delegation_changed || raw_creds_changed
}
fn elicitation_id_from_headers(ext: &Extensions) -> Option<String> {
ext.http
.as_ref()
.and_then(|h| h.get_request_header(ELICITATION_ID_HEADER))
.filter(|v| !v.is_empty())
.map(str::to_string)
}
fn elicitation_peek_from_headers(ext: &Extensions) -> bool {
ext.http
.as_ref()
.and_then(|h| h.get_request_header(ELICITATION_PEEK_HEADER))
.is_some_and(|v| !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0")
}
fn approved_peek_violation(bag: &apl_core::attributes::AttributeBag) -> PluginViolation {
use apl_core::step::elicitation_bag_keys as bk;
let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
if let Some(id) = bag.get_string(bk::ID) {
details.insert("elicitation_id".into(), Value::String(id.to_string()));
}
if let Some(approver) = bag.get_string(bk::APPROVER) {
details.insert("approver".into(), Value::String(approver.to_string()));
}
PluginViolation::new(
"elicitation.approved",
"approved — confirm to apply (re-send without the peek header)".to_string(),
)
.with_proto_error_code(ELICITATION_APPROVED_CODE)
.with_details(details)
}
fn pending_violation(p: &apl_core::step::PendingElicitation) -> PluginViolation {
let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
details.insert("elicitation_id".into(), Value::String(p.id.clone()));
details.insert("plugin".into(), Value::String(p.plugin_name.clone()));
for (key, val) in [
("approver", &p.approver),
("channel", &p.channel),
("expires_at", &p.expires_at),
("intent_id", &p.intent_id),
] {
if let Some(v) = val {
details.insert(key.into(), Value::String(v.clone()));
}
}
PluginViolation::new(
"elicitation.pending",
format!(
"awaiting approval `{}` via `{}` — retry with this id",
p.id, p.plugin_name
),
)
.with_proto_error_code(ELICITATION_PENDING_CODE)
.with_details(details)
}
#[cfg(test)]
mod phase5_tests {
use super::*;
use cpex_core::extensions::HttpExtension;
use std::sync::Arc;
fn pending(id: &str) -> apl_core::step::PendingElicitation {
apl_core::step::PendingElicitation {
id: id.to_string(),
plugin_name: "manager-approver".to_string(),
approver: Some("alice".to_string()),
intent_id: None,
channel: Some("ciba".to_string()),
expires_at: Some("2026-12-31T00:00:00Z".to_string()),
source: "route.payroll.policy[0]".to_string(),
}
}
#[test]
fn pending_violation_carries_minus32120_and_bundle() {
let v = pending_violation(&pending("elic-1"));
assert_eq!(v.proto_error_code, Some(ELICITATION_PENDING_CODE));
assert_eq!(v.code, "elicitation.pending");
assert_eq!(v.details.get("elicitation_id").unwrap(), "elic-1");
assert_eq!(v.details.get("approver").unwrap(), "alice");
assert_eq!(v.details.get("channel").unwrap(), "ciba");
assert_eq!(v.details.get("expires_at").unwrap(), "2026-12-31T00:00:00Z");
assert!(!v.details.contains_key("intent_id"));
}
#[test]
fn elicitation_id_extracted_from_header_case_insensitively() {
let mut http = HttpExtension::default();
http.set_request_header("x-policy-elicitation-id", "elic-42");
let ext = Extensions {
http: Some(Arc::new(http)),
..Extensions::default()
};
assert_eq!(
elicitation_id_from_headers(&ext).as_deref(),
Some("elic-42")
);
}
#[test]
fn no_header_yields_none() {
assert!(elicitation_id_from_headers(&Extensions::default()).is_none());
let mut http = HttpExtension::default();
http.set_request_header(ELICITATION_ID_HEADER, "");
let ext = Extensions {
http: Some(Arc::new(http)),
..Extensions::default()
};
assert!(elicitation_id_from_headers(&ext).is_none());
}
}