apl_cpex/route_handler.rs
1// Location: ./crates/apl-cpex/src/route_handler.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// `AplRouteHandler` — synthetic plugin that drives APL evaluation when
7// cpex-core's `filter_entries_by_route` matches an annotated route. Each
8// instance is bound to ONE phase (Pre or Post) so the unified-config
9// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry
10// distinct handler logic without an in-handler hook-name discriminator.
11//
12// # Why a phase-bound handler
13//
14// The CPEX manager's annotation table is keyed on
15// `(entity_type, entity_name, scope, hook_name)`. The visitor registers
16// one handler per route per phase; the manager picks the right one based
17// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is
18// needed — the handler already knows which phase it's running.
19//
20// # Lifetime / weak manager handle
21//
22// The handler holds `Weak<PluginManager>` because the manager owns the
23// snapshot that owns the annotation that owns the handler — a strong
24// reference would create a cycle. Each `invoke` upgrades to `Arc` for
25// the duration of the call. If the upgrade fails (manager has been
26// dropped) the call returns a configuration error.
27
28use std::sync::{Arc, Weak};
29
30use async_trait::async_trait;
31use serde_json::Value;
32
33use cpex_core::cmf::MessagePayload;
34use cpex_core::context::PluginContext;
35use cpex_core::error::{PluginError, PluginViolation};
36use cpex_core::executor::ErasedResultFields;
37use cpex_core::extensions::Extensions;
38use cpex_core::hooks::PluginPayload;
39use cpex_core::manager::PluginManager;
40use cpex_core::plugin::{Plugin, PluginConfig};
41use cpex_core::registry::AnyHookHandler;
42
43use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
44use apl_cmf::{extract_args, extract_result, BagBuilder};
45use apl_core::evaluator::Decision;
46use apl_core::plugin_decl::PluginRegistry;
47use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
48use apl_core::rules::{CompiledRoute, DenyResponse};
49use apl_core::step::PdpResolver;
50
51use crate::cmf_invoker::CmfPluginInvoker;
52use crate::delegation_invoker::DelegationPluginInvoker;
53use crate::dispatch_plan::DispatchCache;
54use crate::elicitation_invoker::ElicitationPluginInvoker;
55use crate::pdp_router::PdpRouter;
56use crate::session_store::SessionStore;
57
58/// JSON-RPC error code the host emits when a phase suspends on a pending
59/// elicitation: "request not complete — retry echoing the elicitation id."
60/// In the application-reserved JSON-RPC range; carried via
61/// `PluginViolation::proto_error_code` for the host to put on the wire.
62/// The agent SDK keys its pause/resume loop on this code.
63pub const ELICITATION_PENDING_CODE: i64 = -32120;
64
65/// Header an agent echoes on retry to continue a suspended elicitation —
66/// its value is the `elicitation_id` from a prior `-32120`. The handler
67/// seeds it into the bag (`elicitation.id`) before evaluation so the
68/// runtime *checks* the existing elicitation instead of dispatching a new
69/// one. Mirrors how `X-User-Token` carries request-scoped context.
70pub const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id";
71
72/// JSON-RPC error code emitted when an agent re-checks an approval in
73/// *peek* mode and it has resolved approved: "approved — confirm to apply."
74/// The phase does NOT forward to the tool; the agent confirms with the
75/// requester and re-sends *without* the peek header to actually run it.
76/// Lets a human authorize while the requester separately commits execution.
77pub const ELICITATION_APPROVED_CODE: i64 = -32121;
78
79/// Header an agent sets (alongside `X-Policy-Elicitation-Id`) to *peek* at an
80/// approval — resolve its status without committing the action. Truthy
81/// value ("1"/"true"/anything non-empty) enables it.
82pub const ELICITATION_PEEK_HEADER: &str = "X-Policy-Elicitation-Peek";
83
84/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
85/// covers `result` + `post_invocation`. Set once at construction and never
86/// changes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Phase {
89 Pre,
90 Post,
91}
92
93/// Synthetic plugin that drives APL evaluation for one route + one phase.
94///
95/// Implements `Plugin` (so cpex-core treats it like any other plugin —
96/// mode/capabilities/on_error come from the `PluginConfig` the visitor
97/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
98/// executor dispatches into it through the normal type-erased path).
99pub struct AplRouteHandler {
100 config: PluginConfig,
101 route: Arc<CompiledRoute>,
102 phase: Phase,
103 plugin_registry: Arc<PluginRegistry>,
104 dispatch_cache: Arc<DispatchCache>,
105 session_store: Arc<dyn SessionStore>,
106 /// Weak handle to the manager so we can resolve plugin entries +
107 /// dispatch into them by-name. `Weak` avoids the
108 /// manager↔snapshot↔annotation↔handler cycle.
109 manager: Weak<PluginManager>,
110 /// PDP resolver. APL routes that don't use `pdp(...)` steps never
111 /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
112 /// step against an unregistered dialect returns
113 /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
114 /// install resolvers via [`Self::with_pdp`] or
115 /// [`Self::with_pdp_router`].
116 pdp: Arc<dyn PdpResolver>,
117}
118
119impl AplRouteHandler {
120 /// Build a handler. Visitor calls this twice per route — once for
121 /// each phase — and passes the resulting `Arc` to `annotate_route`.
122 pub fn new(
123 config: PluginConfig,
124 route: Arc<CompiledRoute>,
125 phase: Phase,
126 plugin_registry: Arc<PluginRegistry>,
127 dispatch_cache: Arc<DispatchCache>,
128 session_store: Arc<dyn SessionStore>,
129 manager: Weak<PluginManager>,
130 ) -> Self {
131 Self {
132 config,
133 route,
134 phase,
135 plugin_registry,
136 dispatch_cache,
137 session_store,
138 manager,
139 pdp: Arc::new(PdpRouter::new()),
140 }
141 }
142
143 /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
144 /// to support multiple dialects (Cedar + OPA + NeMo) on the same
145 /// route — the router dispatches each `pdp(...)` step by dialect.
146 /// Pass a single resolver when only one dialect is in use; APL
147 /// steps for any other dialect will then return
148 /// `PdpError::NoResolver` at evaluation time.
149 pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
150 self.pdp = pdp;
151 self
152 }
153
154 /// Sugar for the common "register many resolvers" path. Builds a
155 /// [`PdpRouter`], registers each resolver into it, then installs the
156 /// router. Equivalent to constructing a `PdpRouter` by hand and
157 /// passing it to [`Self::with_pdp`].
158 pub fn with_pdp_router(
159 mut self,
160 resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
161 ) -> Self {
162 let mut router = PdpRouter::new();
163 for r in resolvers {
164 router.register(r);
165 }
166 self.pdp = Arc::new(router);
167 self
168 }
169}
170
171#[async_trait]
172impl Plugin for AplRouteHandler {
173 fn config(&self) -> &PluginConfig {
174 &self.config
175 }
176}
177
178#[async_trait]
179impl AnyHookHandler for AplRouteHandler {
180 async fn invoke(
181 &self,
182 payload: &dyn PluginPayload,
183 extensions: &Extensions,
184 _ctx: &mut PluginContext,
185 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
186 // Downcast to the CMF payload — this handler only registers for
187 // cmf.* hook names, so the executor should always hand us a
188 // MessagePayload. A mismatch indicates a framework wiring bug.
189 let msg_payload = payload
190 .as_any()
191 .downcast_ref::<MessagePayload>()
192 .ok_or_else(|| {
193 Box::new(PluginError::Config {
194 message: format!(
195 "AplRouteHandler '{}': payload was not MessagePayload",
196 self.route.route_key
197 ),
198 })
199 })?;
200
201 let manager = self.manager.upgrade().ok_or_else(|| {
202 Box::new(PluginError::Config {
203 message: format!(
204 "AplRouteHandler '{}': PluginManager dropped before invoke",
205 self.route.route_key
206 ),
207 })
208 })?;
209
210 // Build (or reuse) the dispatch plan for this route. Cache keyed
211 // by `(route_key, manager.config_generation())` — if the manager
212 // has reloaded since the last invoke, the next lookup rebuilds.
213 let plan = self
214 .dispatch_cache
215 .get_or_build(&self.route, &self.plugin_registry, &manager)
216 .await;
217
218 // CmfPluginInvoker carries the request-scoped payload + extensions
219 // under interior mutability so successive plugin calls accumulate
220 // mutations. Hydration + persistence are no-ops when there's no
221 // session id (the common case for the first request in a session).
222 // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
223 // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
224 // so `dispatch_parallel` can clone an owned, 'static reference into
225 // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
226 // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
227 // Hydration loads accumulated session labels. A store failure
228 // here happens *before* any policy decision, so we fail the
229 // request closed immediately: deny with a
230 // distinguished violation rather than proceeding as if the
231 // session carried no taint. Sessionless traffic never reaches
232 // the store, so this only denies session-bearing requests.
233 let invoker = match CmfPluginInvoker::for_request(
234 Arc::clone(&manager),
235 extensions.clone(),
236 msg_payload.clone(),
237 plan,
238 Arc::clone(&self.session_store),
239 )
240 .await
241 {
242 Ok(inv) => Arc::new(inv),
243 Err(e) => {
244 tracing::error!(
245 alarm = "session_store_failure",
246 op = "load",
247 route = %self.route.route_key,
248 error = %e,
249 "session label load failed; failing request closed"
250 );
251 let mut v = PluginViolation::new(
252 "session.load_failed",
253 "session state could not be loaded",
254 );
255 decorate_denial_response(&mut v, self.route.response.as_ref());
256 return Ok(Box::new(ErasedResultFields {
257 continue_processing: false,
258 modified_payload: None,
259 modified_extensions: None,
260 violation: Some(v),
261 }));
262 },
263 };
264
265 // Build the attribute bag. APL predicates read flat keys; the
266 // BagBuilder bridges typed CPEX extensions into that namespace.
267 // `route.key` lets default/policy-bundle predicates branch on
268 // which route they're attached to.
269 let post_extensions = invoker.current_extensions().await;
270 let mut bag = BagBuilder::new()
271 .with_extensions(&post_extensions)
272 .with_route_key(&self.route.route_key)
273 .build();
274
275 // Phase 5 retry seeding: if the agent echoed an elicitation id (from
276 // a prior `-32120`) in the `X-Policy-Elicitation-Id` header, seed it
277 // into the bag *before* evaluation. `dispatch_elicitation` then takes
278 // the "id present → check" path (poll the existing approval) instead
279 // of dispatching a fresh one. Without this, every retry would open a
280 // new approval and the loop would never resolve.
281 if let Some(elicitation_id) = elicitation_id_from_headers(&post_extensions) {
282 bag.set(apl_core::step::elicitation_bag_keys::ID, elicitation_id);
283 }
284
285 // Build `RoutePayload.args` from the message. Per-content shape:
286 // * ToolCall → arguments map (JSON Object)
287 // * PromptRequest → arguments map (JSON Object)
288 // * Text-only → JSON String of concatenated text content
289 //
290 // Field pipelines operate on `args.<name>` paths. Result starts
291 // as Null on Pre (no upstream response yet); the Post phase
292 // would extract from a ToolResult / PromptResult — deferred
293 // until result-side handling lands.
294 let args_value = extract_args_from_message(&msg_payload.message);
295 let mut route_payload = match self.phase {
296 Phase::Pre => RoutePayload::new(args_value),
297 Phase::Post => {
298 // Pull the upstream result out of the message so APL
299 // `result.<field>` predicates and the `result:`
300 // pipeline have something to operate on. Falls back to
301 // `Value::Null` when the message has no ToolResult /
302 // PromptResult / Resource content (e.g. for hooks that
303 // fire on entities without a structured result).
304 let result_value = extract_result_from_message(&msg_payload.message);
305 RoutePayload::with_result(args_value, result_value)
306 },
307 };
308
309 // Flatten the call args into the bag under `args.<path>`. APL's
310 // own args pipelines read from `route_payload.args` directly,
311 // but PDP steps and predicates that reference `${args.X}` /
312 // `args.X` resolve through the bag. Mirroring the args here
313 // makes both consumers see the same vocabulary the
314 // `MessageView` exposes. (Bag-mutation via redact during the
315 // args pipeline isn't reflected back into the bag; that's fine
316 // — args predicates today read from `route_payload.args`, and
317 // the cedar substitution snapshots the pre-args view, which is
318 // what an author writing `cedar:(resource.id: ${args.X})` would
319 // expect.)
320 extract_args(&route_payload.args, &mut bag);
321 // Post phase: also project the upstream result into the bag
322 // under `result.<path>`. This is what enables predicates like
323 // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
324 // gates that branch on the result. Pre phases skip this — the
325 // result is `None` by construction.
326 if matches!(self.phase, Phase::Post) {
327 if let Some(result_value) = route_payload.result.as_ref() {
328 extract_result(result_value, &mut bag);
329 }
330 }
331
332 // Slice B: real delegation invoker, sharing the CMF invoker's
333 // extensions Mutex so a `delegate(...)` step's writes to
334 // raw_credentials / delegation are visible to downstream CMF
335 // plugins and to the post phase. Routes that don't declare
336 // any `Step::Delegate` won't have entries in the plan's
337 // `delegation_entries` map; if such a route accidentally hits
338 // `delegate(...)`, the invoker returns `NotFound` and the
339 // evaluator translates it via the step's `on_error`.
340 let delegations = Arc::new(DelegationPluginInvoker::new(
341 Arc::clone(&manager),
342 invoker.extensions_arc(),
343 invoker.plan_arc(),
344 ));
345
346 // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
347 // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
348 // `dispatch_parallel` can then `Arc::clone` an owned 'static
349 // reference into each branch closure.
350 // Elicitation bridge — resolves `require_approval(...)` /
351 // `confirm(...)` steps to `ElicitationHook` plugins by name off
352 // the same plan, sharing the request's Extensions so the handler
353 // reads the same identity. Routes with no elicitation steps have
354 // an empty `elicitation_entries` map; an accidental `Effect::Elicit`
355 // then returns `NotFound`, handled by the step's `on_error`.
356 let elicitations = Arc::new(ElicitationPluginInvoker::new(
357 Arc::clone(&manager),
358 invoker.extensions_arc(),
359 invoker.plan_arc(),
360 ));
361
362 let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
363 let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
364 let elicitations_dyn: Arc<dyn apl_core::step::ElicitationInvoker> = elicitations.clone();
365
366 let decision = match self.phase {
367 Phase::Pre => {
368 evaluate_pre(
369 &self.route,
370 &mut bag,
371 &mut route_payload,
372 &self.pdp,
373 &invoker_dyn,
374 &delegations_dyn,
375 &elicitations_dyn,
376 )
377 .await
378 },
379 Phase::Post => {
380 evaluate_post(
381 &self.route,
382 &mut bag,
383 &mut route_payload,
384 &self.pdp,
385 &invoker_dyn,
386 &delegations_dyn,
387 &elicitations_dyn,
388 )
389 .await
390 },
391 };
392
393 // Drain Session-scoped taints (from `taint(label, session)` /
394 // pipeline `Stage::Taint`) into `extensions.security.labels`
395 // so the existing label-diff flow inside `persist_session`
396 // picks them up. Message-scoped taints are filtered out by
397 // `apply_session_taints` — they need their own destination
398 // (see TS2). No-op when no taints emitted.
399 invoker.apply_session_taints(&decision.taints).await;
400
401 // Commit any session-scoped labels accumulated during this
402 // request. No-op when there was no session id. The result is
403 // folded into the decision below — captured here because
404 // `continue_processing`/`violation` are computed after persist.
405 let persist_result = invoker.persist_session().await;
406
407 // Surface the final mutated payload + extensions back into the
408 // PipelineResult the executor returns to the host. The host's
409 // body re-serialization picks up edits made by APL pipelines
410 // (e.g. a redact stage that rewrote args.text).
411 let final_payload = invoker.current_payload().await;
412 let final_extensions = invoker.current_extensions().await;
413
414 // Detect whether the args pipeline mutated the payload by
415 // re-extracting from the pre-eval message (msg_payload is
416 // still borrowed) and comparing against the post-eval
417 // route_payload.args. Re-extraction allocates but mirrors the
418 // surrounding pattern and avoids holding a pre-eval clone.
419 let pre_args = extract_args_from_message(&msg_payload.message);
420 // For Post phase, also detect result mutations from `result:`
421 // pipelines. Pre routes don't carry a result so this is None.
422 let pre_result = match self.phase {
423 Phase::Pre => None,
424 Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
425 };
426 let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
427 // An args pipeline (Pre) rewrote a field. Fold the new
428 // args back into a fresh MessagePayload so downstream
429 // readers (the host's body re-serializer) see the
430 // change.
431 let mut updated = final_payload.clone();
432 write_args_back_to_message(&mut updated.message, &route_payload.args);
433 Some(Box::new(updated) as Box<dyn PluginPayload>)
434 } else if matches!(self.phase, Phase::Post)
435 && pre_result
436 .as_ref()
437 .zip(route_payload.result.as_ref())
438 .map(|(prev, current)| prev != current)
439 .unwrap_or(false)
440 {
441 // A `result:` pipeline rewrote a field in the upstream
442 // response. Fold the new result back into the message
443 // so the host's response body re-serializer can write
444 // it out before forwarding downstream.
445 let mut updated = final_payload.clone();
446 if let Some(result_value) = route_payload.result.as_ref() {
447 write_result_back_to_message(&mut updated.message, result_value);
448 }
449 Some(Box::new(updated) as Box<dyn PluginPayload>)
450 } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
451 {
452 // A `pre_invocation:` plugin mutated the message directly via
453 // `modify_payload` (not through a field pipeline). Pass
454 // the invoker's view through unchanged.
455 Some(Box::new(final_payload) as Box<dyn PluginPayload>)
456 } else {
457 None
458 };
459
460 let modified_extensions = if extensions_changed(extensions, &final_extensions) {
461 Some(final_extensions.cow_copy())
462 } else {
463 None
464 };
465
466 // A suspended phase reports `Allow` with a pending bundle — it
467 // must NOT forward. Fail closed with a distinguished violation that
468 // carries the elicitation id (mapped to JSON-RPC `-32120`) so the
469 // suspend is visible and the unapproved call never proceeds.
470 let pending_elicitation = decision.pending.clone();
471
472 // Attach the route's transpiled `denyWith` to a violation at each
473 // genuine-denial site (below) via `decorate_denial_response`, rather
474 // than blanket-decorating whatever `violation` is set. This keeps the
475 // custom response off any future non-denial signal (e.g. an
476 // elicitation/retry/confirm violation) that must reach the host with
477 // its own wire shape intact.
478 let (mut continue_processing, mut violation) = match decision.decision {
479 Decision::Allow => (true, None),
480 Decision::Deny {
481 reason,
482 rule_source,
483 } => {
484 let code = if rule_source.is_empty() {
485 "policy.deny".to_string()
486 } else {
487 rule_source
488 };
489 let reason = reason.unwrap_or_else(|| "access denied".to_string());
490 let mut v = PluginViolation::new(code, reason);
491 decorate_denial_response(&mut v, self.route.response.as_ref());
492 (false, Some(v))
493 },
494 };
495
496 if let Some(p) = &pending_elicitation {
497 tracing::info!(
498 route = %self.route.route_key,
499 elicitation_id = %p.id,
500 plugin = %p.plugin_name,
501 "policy suspended on pending elicitation; emitting -32120 (retry)"
502 );
503 // The phase suspended awaiting a human. Do NOT forward. Surface
504 // a structured "request not complete — retry echoing this id"
505 // via the protocol error code the host maps to the wire
506 // (JSON-RPC `-32120`). Left undecorated by `denyWith` — it is a
507 // retry signal, not a denial.
508 continue_processing = false;
509 violation = Some(pending_violation(p));
510 }
511
512 // Peek (confirm-then-apply): the agent re-checked an approval but
513 // asked NOT to commit yet (the `X-Policy-Elicitation-Peek` header). If
514 // the elicitation resolved approved (Allow, not pending), report
515 // "approved — confirm to apply" (-32121) and do NOT forward. The
516 // agent then asks the requester, who re-sends without the peek header
517 // to actually run the tool (the plugin replays the cached approval).
518 if continue_processing
519 && elicitation_peek_from_headers(&post_extensions)
520 && bag.get_string(apl_core::step::elicitation_bag_keys::OUTCOME) == Some("approved")
521 {
522 continue_processing = false;
523 violation = Some(approved_peek_violation(&bag));
524 }
525
526 // Append fail-closed with merge precedence:
527 // - decision Allow + append Err → flip to Deny with a
528 // distinguished `session.persist_failed` violation.
529 // - decision Deny + append Err → keep the original policy
530 // violation (preserve attribution); the request is already
531 // denied. The append failure surfaces only as the alarm.
532 // The alarm/metric fires on every append failure regardless of
533 // decision, since the dangerous residual is a *selective*
534 // failure (append rejected while reads still succeed).
535 if let Err(e) = persist_result {
536 tracing::error!(
537 alarm = "session_store_failure",
538 op = "append",
539 route = %self.route.route_key,
540 decision_was_allow = continue_processing,
541 error = %e,
542 "session label persist failed; failing request closed"
543 );
544 if continue_processing {
545 continue_processing = false;
546 let mut v = PluginViolation::new(
547 "session.persist_failed",
548 "session state could not be persisted",
549 );
550 decorate_denial_response(&mut v, self.route.response.as_ref());
551 violation = Some(v);
552 }
553 }
554
555 Ok(Box::new(ErasedResultFields {
556 continue_processing,
557 modified_payload,
558 modified_extensions,
559 violation,
560 }))
561 }
562
563 fn hook_type_name(&self) -> &'static str {
564 // CmfHook::NAME — kept as a literal here to avoid pulling in the
565 // HookTypeDef trait just for the constant.
566 "cmf"
567 }
568}
569
570// =====================================================================
571// Helpers
572// =====================================================================
573
574/// Attach a route's transpiled `denyWith` (status/body/headers) to a
575/// denial `violation`'s `details` map so the host can render a custom HTTP
576/// denial response. Carried via `details` (not new violation fields) to
577/// keep the violation type stable. `None` response leaves the host default.
578///
579/// Call this only from genuine-denial sites — never blanket-apply it to
580/// whatever violation happens to be set, or a non-denial signal (e.g. an
581/// elicitation/retry/confirm) would get stamped with a `403`-shaped
582/// response the host would render instead of the intended wire signal.
583fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
584 let Some(resp) = response else {
585 return;
586 };
587 if let Some(status) = resp.status {
588 violation
589 .details
590 .insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
591 }
592 if let Some(body) = &resp.body {
593 violation
594 .details
595 .insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
596 }
597 if !resp.headers.is_empty() {
598 violation.details.insert(
599 DETAIL_HTTP_HEADERS.to_string(),
600 serde_json::json!(resp.headers),
601 );
602 }
603}
604
605/// Rewrite the first text part of `msg` with `new_text`. If there is no
606/// text part, append one. Mirrors what `MessagePayload`'s normal
607/// modify-path does for single-view v0.
608fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
609 for part in msg.content.iter_mut() {
610 if let cpex_core::cmf::ContentPart::Text { text } = part {
611 *text = new_text.to_string();
612 return;
613 }
614 }
615 msg.content.push(cpex_core::cmf::ContentPart::Text {
616 text: new_text.to_string(),
617 });
618}
619
620/// Extract `RoutePayload.args` from a CMF message. v0 maps:
621/// * First `ContentPart::ToolCall` → `arguments` map (Object)
622/// * First `ContentPart::PromptRequest` → `arguments` map (Object)
623/// * Else (text / no entity parts) → JSON String of text content
624///
625/// `args.<field>` APL paths target tool / prompt arguments directly.
626/// For text-only messages we fall back to the v0 "args = whole text"
627/// shape so `args.text` predicates keep working.
628fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
629 use cpex_core::cmf::ContentPart;
630 for part in &msg.content {
631 match part {
632 ContentPart::ToolCall { content } => {
633 return Value::Object(
634 content
635 .arguments
636 .iter()
637 .map(|(k, v)| (k.clone(), v.clone()))
638 .collect(),
639 );
640 },
641 ContentPart::PromptRequest { content } => {
642 return Value::Object(
643 content
644 .arguments
645 .iter()
646 .map(|(k, v)| (k.clone(), v.clone()))
647 .collect(),
648 );
649 },
650 _ => {},
651 }
652 }
653 Value::String(msg.get_text_content())
654}
655
656/// Inverse of [`extract_args_from_message`]: write `args` back into
657/// `msg`'s first ToolCall / PromptRequest argument map, or — for
658/// text payloads — into the first text part.
659///
660/// Silently no-ops when the args shape doesn't match the message
661/// content shape (e.g. operator pipeline produced a String for what
662/// was originally a ToolCall). The mismatch path is recoverable —
663/// the upstream just sees the original unmodified content rather
664/// than a malformed rewrite.
665fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
666 use cpex_core::cmf::ContentPart;
667 for part in msg.content.iter_mut() {
668 match part {
669 ContentPart::ToolCall { content } => {
670 if let Some(obj) = args.as_object() {
671 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
672 }
673 return;
674 },
675 ContentPart::PromptRequest { content } => {
676 if let Some(obj) = args.as_object() {
677 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
678 }
679 return;
680 },
681 _ => {},
682 }
683 }
684 // Fall through: no structured entity part — treat as text.
685 if let Some(text) = args.as_str() {
686 rewrite_message_text(msg, text);
687 }
688}
689
690/// Extract `RoutePayload.result` from a CMF message. Mirror of
691/// [`extract_args_from_message`] for the Post phase. v0 maps:
692/// * First `ContentPart::ToolResult` → its `content` JSON value
693/// * Else (text / no structured result part) → JSON String of text
694///
695/// `result.<field>` APL paths target the structured result directly.
696fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
697 use cpex_core::cmf::ContentPart;
698 for part in &msg.content {
699 if let ContentPart::ToolResult { content } = part {
700 return content.content.clone();
701 }
702 }
703 Value::String(msg.get_text_content())
704}
705
706/// Inverse of [`extract_result_from_message`]: write a mutated
707/// `result` back into the message's first `ContentPart::ToolResult.content`,
708/// or — for text-only messages — into the first text part. The praxis
709/// filter's response-body re-serializer then lifts the new content
710/// out of the ContentPart and folds it back into the JSON-RPC
711/// `result.content[*].text` payload.
712fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
713 use cpex_core::cmf::ContentPart;
714 for part in msg.content.iter_mut() {
715 if let ContentPart::ToolResult { content } = part {
716 content.content = result.clone();
717 return;
718 }
719 }
720 if let Some(text) = result.as_str() {
721 rewrite_message_text(msg, text);
722 }
723}
724
725/// Cheap pointer-equality check across the few mutable extension slots
726/// the executor would care about. False positives (claiming a change
727/// when there isn't one) are cheap — the executor re-validates anyway.
728fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
729 let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
730 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
731 (None, None) => false,
732 _ => true,
733 };
734 let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
735 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
736 (None, None) => false,
737 _ => true,
738 };
739 // `delegate(...)` steps write minted tokens into
740 // `raw_credentials.delegated_tokens` via the shared Mutex —
741 // without this check, a route whose only Extensions mutation is
742 // a delegate (no security / delegation chain edit) looks
743 // unchanged, so the executor never merges the minted token back
744 // and downstream readers (our HttpFilter attaching the token to
745 // the upstream request) see nothing.
746 let raw_creds_changed = match (
747 before.raw_credentials.as_ref(),
748 after.raw_credentials.as_ref(),
749 ) {
750 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
751 (None, None) => false,
752 _ => true,
753 };
754 security_changed || delegation_changed || raw_creds_changed
755}
756
757// ---------------------------------------------------------------------
758// Phase 5: pending elicitation ↔ wire (`-32120`)
759// ---------------------------------------------------------------------
760
761/// Extract the elicitation id an agent echoes on retry from the
762/// `X-Policy-Elicitation-Id` request header. `None` when absent/empty.
763/// Pure so it's unit-testable without the full handler path.
764fn elicitation_id_from_headers(ext: &Extensions) -> Option<String> {
765 ext.http
766 .as_ref()
767 .and_then(|h| h.get_request_header(ELICITATION_ID_HEADER))
768 .filter(|v| !v.is_empty())
769 .map(str::to_string)
770}
771
772/// True when the agent set `X-Policy-Elicitation-Peek` to a truthy value —
773/// it wants to resolve the approval's status without committing the action.
774fn elicitation_peek_from_headers(ext: &Extensions) -> bool {
775 ext.http
776 .as_ref()
777 .and_then(|h| h.get_request_header(ELICITATION_PEEK_HEADER))
778 .is_some_and(|v| !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0")
779}
780
781/// Build the `-32121` "approved — confirm to apply" violation for a peek
782/// that resolved approved. Carries the elicitation id + approver in
783/// `details` so the agent can ask the requester and then re-send (without
784/// the peek header) to actually run the tool.
785fn approved_peek_violation(bag: &apl_core::attributes::AttributeBag) -> PluginViolation {
786 use apl_core::step::elicitation_bag_keys as bk;
787 let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
788 if let Some(id) = bag.get_string(bk::ID) {
789 details.insert("elicitation_id".into(), Value::String(id.to_string()));
790 }
791 if let Some(approver) = bag.get_string(bk::APPROVER) {
792 details.insert("approver".into(), Value::String(approver.to_string()));
793 }
794 PluginViolation::new(
795 "elicitation.approved",
796 "approved — confirm to apply (re-send without the peek header)".to_string(),
797 )
798 .with_proto_error_code(ELICITATION_APPROVED_CODE)
799 .with_details(details)
800}
801
802/// Build the `-32120` violation for a suspended phase: a distinguished
803/// code, the protocol error code the host maps to the wire, and the
804/// elicitation bundle in `details` so the agent can show who's approving /
805/// when it expires and retry by re-sending the id.
806fn pending_violation(p: &apl_core::step::PendingElicitation) -> PluginViolation {
807 let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
808 details.insert("elicitation_id".into(), Value::String(p.id.clone()));
809 details.insert("plugin".into(), Value::String(p.plugin_name.clone()));
810 for (key, val) in [
811 ("approver", &p.approver),
812 ("channel", &p.channel),
813 ("expires_at", &p.expires_at),
814 ("intent_id", &p.intent_id),
815 ] {
816 if let Some(v) = val {
817 details.insert(key.into(), Value::String(v.clone()));
818 }
819 }
820 PluginViolation::new(
821 "elicitation.pending",
822 format!(
823 "awaiting approval `{}` via `{}` — retry with this id",
824 p.id, p.plugin_name
825 ),
826 )
827 .with_proto_error_code(ELICITATION_PENDING_CODE)
828 .with_details(details)
829}
830
831#[cfg(test)]
832mod phase5_tests {
833 use super::*;
834 use cpex_core::extensions::HttpExtension;
835 use std::sync::Arc;
836
837 fn pending(id: &str) -> apl_core::step::PendingElicitation {
838 apl_core::step::PendingElicitation {
839 id: id.to_string(),
840 plugin_name: "manager-approver".to_string(),
841 approver: Some("alice".to_string()),
842 intent_id: None,
843 channel: Some("ciba".to_string()),
844 expires_at: Some("2026-12-31T00:00:00Z".to_string()),
845 source: "route.payroll.policy[0]".to_string(),
846 }
847 }
848
849 #[test]
850 fn pending_violation_carries_minus32120_and_bundle() {
851 let v = pending_violation(&pending("elic-1"));
852 assert_eq!(v.proto_error_code, Some(ELICITATION_PENDING_CODE));
853 assert_eq!(v.code, "elicitation.pending");
854 assert_eq!(v.details.get("elicitation_id").unwrap(), "elic-1");
855 assert_eq!(v.details.get("approver").unwrap(), "alice");
856 assert_eq!(v.details.get("channel").unwrap(), "ciba");
857 assert_eq!(v.details.get("expires_at").unwrap(), "2026-12-31T00:00:00Z");
858 // Absent optional → not in details.
859 assert!(!v.details.contains_key("intent_id"));
860 }
861
862 #[test]
863 fn elicitation_id_extracted_from_header_case_insensitively() {
864 let mut http = HttpExtension::default();
865 http.set_request_header("x-policy-elicitation-id", "elic-42");
866 let ext = Extensions {
867 http: Some(Arc::new(http)),
868 ..Extensions::default()
869 };
870 assert_eq!(
871 elicitation_id_from_headers(&ext).as_deref(),
872 Some("elic-42")
873 );
874 }
875
876 #[test]
877 fn no_header_yields_none() {
878 // No http extension at all.
879 assert!(elicitation_id_from_headers(&Extensions::default()).is_none());
880 // Header present but empty → treated as absent.
881 let mut http = HttpExtension::default();
882 http.set_request_header(ELICITATION_ID_HEADER, "");
883 let ext = Extensions {
884 http: Some(Arc::new(http)),
885 ..Extensions::default()
886 };
887 assert!(elicitation_id_from_headers(&ext).is_none());
888 }
889}