apl_cpex/visitor.rs
1// Location: ./crates/apl-cpex/src/visitor.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `AplConfigVisitor` — the cpex-core `ConfigVisitor` implementation that
7// stacks the unified-config hierarchy (global → defaults → tag bundles
8// → routes) into a single `CompiledRoute` per route and installs an
9// [`AplRouteHandler`] for each phase via `PluginManager::annotate_route`.
10//
11// # Hierarchy stacking
12//
13// Each `visit_*` call carries a single block of raw YAML. The visitor
14// finds the `apl:` sub-block (if any), compiles it to a `CompiledRoute`,
15// and stashes it in interior state:
16//
17// visit_global → state.global_layer
18// visit_default → state.default_layers[entity_type]
19// visit_policy_bundle → state.tag_layers[tag]
20// visit_route → build effective route by layering and annotate.
21//
22// At `visit_route` we layer least-to-most-specific:
23//
24// effective = global
25// effective.apply_layer(default_layer_for(entity_type))
26// for tag in route.meta.tags { effective.apply_layer(tag_layer(tag)) }
27// effective.apply_layer(route_apl_block)
28//
29// then construct one `AplRouteHandler` per phase (Pre, Post) and call
30// `annotate_route` for each `(entity_type, entity_name, scope, hook)`.
31//
32// # Hook names per entity type
33//
34// Each entity type binds to its own CMF hook pair:
35//
36// * `tool:` → `cmf.tool_pre_invoke` / `cmf.tool_post_invoke`
37// * `llm:` → `cmf.llm_input` / `cmf.llm_output`
38// * `prompt:` → `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke`
39// * `resource:` → `cmf.resource_pre_fetch` / `cmf.resource_post_fetch`
40//
41// The mapping lives in [`hook_pair_for_entity`]. Hosts fire
42// `mgr.invoke_named::<CmfHook>("cmf.llm_input", ...)` for LLM
43// invocations; the visitor's annotation on `cmf.llm_input` for the
44// matching route's entity_name is what AplRouteHandler intercepts.
45//
46// `tool_pre_invoke` / `tool_post_invoke` are exposed as legacy
47// re-exports for callers that wired against the v0 constants — the
48// per-entity dispatch is the load-bearing path now.
49
50use std::collections::HashMap;
51use std::sync::{Arc, RwLock, Weak};
52
53use cpex_core::cmf::constants::{
54 ENTITY_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL,
55 HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
56 HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
57 HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
58};
59use cpex_core::config::RouteEntry;
60use cpex_core::manager::PluginManager;
61use cpex_core::plugin::PluginConfig;
62use cpex_core::visitor::{ConfigVisitor, VisitorError};
63
64use apl_core::parser::compile_policy_block_value;
65use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry};
66use apl_core::rules::{CompiledRoute, DenyResponse};
67use apl_core::step::{PdpFactory, PdpResolver};
68
69use crate::dispatch_plan::DispatchCache;
70use crate::pdp_router::PdpRouter;
71use crate::route_handler::{AplRouteHandler, Phase};
72use crate::session_store::{SessionStore, SessionStoreFactory};
73
74/// Legacy alias for the tool-family pre hook. Kept exported for
75/// callers that wired against the v0 visitor constants — the
76/// per-entity-type dispatch via `hook_pair_for_entity` is the
77/// load-bearing path now.
78pub const HOOK_PRE: &str = HOOK_CMF_TOOL_PRE_INVOKE;
79/// Legacy alias for the tool-family post hook. See `HOOK_PRE`.
80pub const HOOK_POST: &str = HOOK_CMF_TOOL_POST_INVOKE;
81
82/// Resolve the (pre, post) CMF hook pair for an entity_type. Drives
83/// per-entity `annotate_route` calls so an `llm:` route annotates on
84/// `cmf.llm_input` / `cmf.llm_output` rather than the tool-family
85/// hooks. Returns `None` for unknown entity types — the visitor logs
86/// + skips those routes.
87fn hook_pair_for_entity(entity_type: &str) -> Option<(&'static str, &'static str)> {
88 match entity_type {
89 ENTITY_TOOL => Some((HOOK_CMF_TOOL_PRE_INVOKE, HOOK_CMF_TOOL_POST_INVOKE)),
90 ENTITY_LLM => Some((HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT)),
91 ENTITY_PROMPT => Some((HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_PROMPT_POST_INVOKE)),
92 ENTITY_RESOURCE => Some((HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_RESOURCE_POST_FETCH)),
93 _ => None,
94 }
95}
96
97/// Interior state accumulated as the manager walks the visitor.
98/// `plugin_registry` is populated by `visit_plugins` (called once per
99/// load); the layer fields are populated as the visitor walks
100/// `global` / `defaults` / `policies` / `routes`; `pdp_router` is
101/// populated by both code-supplied resolvers (`register_pdp`) and
102/// unified-config-driven entries under `global.apl.pdp[]` (built
103/// during `visit_global`).
104#[derive(Default)]
105struct VisitorState {
106 plugin_registry: PluginRegistry,
107 global_layer: Option<CompiledRoute>,
108 default_layers: HashMap<String, CompiledRoute>,
109 tag_layers: HashMap<String, CompiledRoute>,
110 pdp_router: PdpRouter,
111}
112
113/// APL implementation of [`cpex_core::visitor::ConfigVisitor`]. Construct
114/// once per host with the shared infrastructure (dispatch cache, session
115/// store, manager handle) and register with `PluginManager::register_visitor`
116/// before calling `load_config_yaml`.
117///
118/// PDPs come from two sources, both feeding the same internal
119/// [`PdpRouter`]:
120///
121/// 1. **Code-supplied** via `register_pdp` (or `AplOptions.pdps`) —
122/// the host built the resolver in code and hands it in.
123/// 2. **Config-supplied** via `global.apl.pdp[]` blocks in the unified
124/// config — the visitor sees the block, looks up a factory by
125/// `kind`, and constructs the resolver during `visit_global`.
126///
127/// Factories are registered up front by `kind` name (`"cedar-direct"`,
128/// `"opa"`, …). The visitor knows nothing about specific PDP
129/// backends; everything dispatches through `PdpFactory`.
130pub struct AplConfigVisitor {
131 state: RwLock<VisitorState>,
132 dispatch_cache: Arc<DispatchCache>,
133 /// Active session store. Behind a `RwLock` because a
134 /// `global.apl.session_store` block can swap it during the
135 /// config walk (`visit_global`), which runs before route handlers
136 /// capture the store in `visit_route`. Only touched during the
137 /// single-threaded config walk — never on the request hot path,
138 /// where each handler holds its own cloned `Arc`.
139 session_store: RwLock<Arc<dyn SessionStore>>,
140 manager: Weak<PluginManager>,
141 /// Baseline capabilities granted to every synthetic `AplRouteHandler`
142 /// the visitor installs. Unioned with the per-route plugin
143 /// capability set so APL predicates that touch extensions
144 /// (`require(authenticated)` needs `read_subject`, etc.) work even
145 /// when no plugins are referenced. Hosts that want strict gating
146 /// can set this to an empty set.
147 base_capabilities: std::collections::HashSet<String>,
148 /// Factories the visitor consults when it encounters a
149 /// `global.apl.pdp[]` entry. Keyed by the factory's `kind()` —
150 /// matches the `kind:` field in the YAML block.
151 pdp_factories: HashMap<String, Arc<dyn PdpFactory>>,
152 /// Factories the visitor consults for a `global.apl.session_store`
153 /// block. Keyed by the factory's `kind()`. Empty by default, in
154 /// which case the constructor-supplied store (typically
155 /// `MemorySessionStore`) stays active.
156 session_store_factories: HashMap<String, Arc<dyn SessionStoreFactory>>,
157}
158
159impl AplConfigVisitor {
160 pub fn new(
161 dispatch_cache: Arc<DispatchCache>,
162 session_store: Arc<dyn SessionStore>,
163 manager: Weak<PluginManager>,
164 ) -> Self {
165 Self {
166 state: RwLock::new(VisitorState::default()),
167 dispatch_cache,
168 session_store: RwLock::new(session_store),
169 manager,
170 base_capabilities: default_base_capabilities(),
171 pdp_factories: HashMap::new(),
172 session_store_factories: HashMap::new(),
173 }
174 }
175
176 /// Register a code-supplied PDP resolver. Equivalent to declaring a
177 /// PDP in the unified config but for hosts that prefer wiring
178 /// resolvers in Rust. Resolvers are pushed into the internal
179 /// `PdpRouter`; the first registration per dialect wins (matches
180 /// `PdpRouter::register` semantics).
181 pub fn register_pdp(&self, resolver: Arc<dyn PdpResolver>) {
182 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
183 state.pdp_router.register(resolver);
184 }
185
186 /// Register a PDP factory by its `kind()`. Called during
187 /// `register_apl` setup; the visitor uses these to instantiate
188 /// resolvers from `global.apl.pdp[]` config blocks.
189 pub fn register_pdp_factory(&mut self, factory: Arc<dyn PdpFactory>) {
190 self.pdp_factories
191 .insert(factory.kind().to_string(), factory);
192 }
193
194 /// Register a `SessionStoreFactory` by its `kind()`. Called during
195 /// `register_apl` setup; the visitor uses these to swap in the
196 /// config-selected session store when it sees a
197 /// `global.apl.session_store` block.
198 pub fn register_session_store_factory(&mut self, factory: Arc<dyn SessionStoreFactory>) {
199 self.session_store_factories
200 .insert(factory.kind().to_string(), factory);
201 }
202
203 /// Parse the optional `global.apl.session_store` block and swap the
204 /// active store. Looks up the factory by `kind`, builds the store,
205 /// and replaces the constructor-supplied default. Runs during
206 /// `visit_global` — before `visit_route` clones the store into each
207 /// handler — so the selected store is the one handlers capture.
208 /// Absent block → no-op (the default store stays active).
209 fn build_session_store_from_config(
210 &self,
211 block: &serde_yaml::Value,
212 ) -> Result<(), VisitorError> {
213 let map = block.as_mapping().ok_or_else(|| {
214 "global.apl.session_store must be a mapping with a `kind:` field".to_string()
215 })?;
216 let kind = map
217 .get(serde_yaml::Value::String("kind".to_string()))
218 .and_then(|v| v.as_str())
219 .ok_or_else(|| "global.apl.session_store missing required `kind:` field".to_string())?;
220 let factory = self.session_store_factories.get(kind).ok_or_else(|| {
221 format!(
222 "global.apl.session_store declared kind='{}' but no factory is registered for that \
223 kind — host must call register_session_store_factory(...) before load_config_yaml",
224 kind
225 )
226 })?;
227 let store = factory.build(block).map_err(|e| {
228 format!(
229 "global.apl.session_store (kind='{}') failed to build: {}",
230 kind, e
231 )
232 })?;
233 *self
234 .session_store
235 .write()
236 .unwrap_or_else(|p| p.into_inner()) = store;
237 Ok(())
238 }
239
240 /// Replace the baseline capability set granted to every installed
241 /// `AplRouteHandler`. Default covers read-only attributes APL
242 /// predicates commonly touch (subject, role, labels, delegation,
243 /// agent). Tighten this when the deployment's policy plugins
244 /// don't need broad reads — every cap removed is one fewer
245 /// extension slot a buggy predicate can leak through.
246 pub fn with_base_capabilities(mut self, caps: std::collections::HashSet<String>) -> Self {
247 self.base_capabilities = caps;
248 self
249 }
250
251 /// Parse one entry from `global.apl.pdp[]`. Reads `kind`, dispatches
252 /// to the matching factory, installs the resulting resolver into
253 /// the internal `PdpRouter`. Called per entry during `visit_global`.
254 ///
255 /// `index` is used only for diagnostics — operators see "the third
256 /// pdp entry failed" rather than a generic "a pdp entry failed."
257 fn build_pdp_from_config(
258 &self,
259 entry: &serde_yaml::Value,
260 index: usize,
261 ) -> Result<(), VisitorError> {
262 let map = entry.as_mapping().ok_or_else(|| {
263 format!(
264 "global.apl.pdp[{}] must be a mapping with a `kind:` field",
265 index
266 )
267 })?;
268 let kind = map
269 .get(serde_yaml::Value::String("kind".to_string()))
270 .and_then(|v| v.as_str())
271 .ok_or_else(|| format!("global.apl.pdp[{}] missing required `kind:` field", index))?;
272 let factory = self.pdp_factories.get(kind).ok_or_else(|| {
273 format!(
274 "global.apl.pdp[{}] declared kind='{}' but no factory is registered for that kind — \
275 host must call register_pdp_factory(...) before load_config_yaml",
276 index, kind
277 )
278 })?;
279 let resolver = factory.build(entry).map_err(|e| {
280 format!(
281 "global.apl.pdp[{}] (kind='{}') failed to build: {}",
282 index, kind, e
283 )
284 })?;
285 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
286 state.pdp_router.register(resolver);
287 Ok(())
288 }
289
290 /// Snapshot the request-time dispatch state — plugin registry, PDP
291 /// router, and active session store — each `Arc`-wrapped for a handler
292 /// to capture. Reads the visitor's `RwLock`s once through a single
293 /// poison-recovery path shared by both handler-install sites
294 /// (`visit_global`'s entity-less HTTP handler and `visit_route`'s
295 /// per-entity handlers) so the policy can't diverge between them.
296 fn snapshot_dispatch_state(
297 &self,
298 ) -> (
299 Arc<PluginRegistry>,
300 Arc<dyn PdpResolver>,
301 Arc<dyn SessionStore>,
302 ) {
303 let (plugin_registry, pdp_router_arc) = {
304 let state = self.state.read().unwrap_or_else(|p| p.into_inner());
305 (
306 Arc::new(state.plugin_registry.clone()),
307 Arc::new(state.pdp_router.clone()) as Arc<dyn PdpResolver>,
308 )
309 };
310 let session_store = self
311 .session_store
312 .read()
313 .unwrap_or_else(|p| p.into_inner())
314 .clone();
315 (plugin_registry, pdp_router_arc, session_store)
316 }
317}
318
319/// Read-only baseline for APL predicates: enough to make
320/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`,
321/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`,
322/// and `agent.*` evaluate correctly. Excludes all *write* capabilities
323/// — those are granted on demand by the per-route plugin union when a
324/// plugin declares `append_labels` / `append_delegation` /
325/// `write_headers`.
326///
327/// `read_subject` alone unlocks only `subject.id` / `subject.type`;
328/// roles, permissions, teams, and claims are each gated by their own
329/// capability (`read_roles` / `read_permissions` / `read_teams` /
330/// `read_claims`). PDP-driven policies routinely read principal.roles /
331/// principal.claims, so the baseline grants all four — tightening
332/// further would surprise APL authors whose `cedar:` policies suddenly
333/// see empty role sets in deployments with no plugin-declared caps.
334/// Hosts that want strict subject access override this via
335/// `AplOptions.base_capabilities`.
336fn default_base_capabilities() -> std::collections::HashSet<String> {
337 [
338 "read_subject",
339 "read_roles",
340 "read_permissions",
341 "read_teams",
342 "read_claims",
343 "read_labels",
344 "read_delegation",
345 "read_agent",
346 "read_meta",
347 ]
348 .iter()
349 .map(|s| s.to_string())
350 .collect()
351}
352
353impl ConfigVisitor for AplConfigVisitor {
354 fn name(&self) -> &str {
355 "apl"
356 }
357
358 fn visit_plugins(
359 &self,
360 _mgr: &Arc<PluginManager>,
361 plugins: &[PluginConfig],
362 ) -> Result<(), VisitorError> {
363 // Translate cpex-core's typed PluginConfig into apl-core's
364 // PluginDeclaration. Field-for-field except `capabilities` is a
365 // `HashSet` on the cpex side and a `Vec` on the apl side, and
366 // `config` is wrapped in `serde_yaml::Value::Mapping` to match
367 // apl-core's opaque shape. cpex-core has already validated
368 // uniqueness by this point so we don't re-check.
369 let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
370 state.plugin_registry.clear();
371 for cfg in plugins {
372 let decl = PluginDeclaration {
373 name: cfg.name.clone(),
374 kind: cfg.kind.clone(),
375 hooks: cfg.hooks.clone(),
376 capabilities: cfg.capabilities.iter().cloned().collect(),
377 config: plugin_config_to_yaml(&cfg.config),
378 on_error: Some(on_error_to_string(&cfg.on_error)),
379 extra: HashMap::new(),
380 };
381 state.plugin_registry.insert(cfg.name.clone(), decl);
382 }
383 Ok(())
384 }
385
386 fn visit_global(
387 &self,
388 mgr: &Arc<PluginManager>,
389 yaml: &serde_yaml::Value,
390 ) -> Result<(), VisitorError> {
391 reject_legacy_apl_keys("global", yaml)?;
392 let Some(apl_block) = apl_subblock(yaml) else {
393 // No `apl:` wrapper and no flat DSL keys — there is nothing to
394 // compile or install. But a bare `global: { response: {...} }`
395 // (a denyWith with no accompanying policy) would otherwise be
396 // dropped here silently, before the `response_subblock` read
397 // below ever runs. Warn so this fail-open-by-omission case gets
398 // the same signal as the args/policy-empty case handled further
399 // down, rather than vanishing without a trace.
400 if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
401 tracing::warn!(
402 "APL visitor: global.response is set but global.apl has no policy/args block \
403 — the entity-less HTTP catch-all handler will not install, so this response can never fire",
404 );
405 }
406 return Ok(());
407 };
408
409 // Process `apl.pdp[]` before stacking the pre/post-invocation
410 // layer — route handlers that reference PDPs need them
411 // resolvable by the time `visit_route` runs.
412 if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) {
413 for (i, entry) in pdp_entries.iter().enumerate() {
414 self.build_pdp_from_config(entry, i)?;
415 }
416 }
417
418 // Process an optional `global.apl.session_store` block: swap the
419 // active store before `visit_route` clones it into handlers.
420 if let Some(block) = apl_block.get("session_store") {
421 self.build_session_store_from_config(block)?;
422 }
423
424 // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields;
425 // strip them before handing the block to
426 // `compile_policy_block_value` so the compiler doesn't see unknown
427 // keys. `compile_policy_block_value` accepts maps with
428 // `authorization:` / `pre_invocation:` / `post_invocation:` /
429 // `args:` / `result:` / `plugins:` (and inert fields it ignores),
430 // so a shallow strip on a clone is enough.
431 let policy_only = strip_non_dsl_keys(&apl_block);
432 let mut compiled = compile_policy_block_value("global.apl", &policy_only)
433 .map_err(|e| Box::new(e) as VisitorError)?;
434 // A `response:` block at the global scope is the catch-all denyWith.
435 compiled.response = response_subblock(yaml, "global");
436
437 // Install a catch-all handler so the global policy also evaluates for
438 // generic (non-MCP/A2A) HTTP requests, which carry no entity.
439 // Entity routes still stack `global` via apply_layer in visit_route;
440 // this is the *entity-less* evaluation path. Pre-phase only —
441 // authorization is an admission check, so there is no post handler.
442 let installs_pre_handler = http_catchall_should_install(&compiled);
443 if !installs_pre_handler && compiled.response.is_some() {
444 tracing::warn!(
445 "APL visitor: global.response is set but global.apl has no `args:`/`policy:` steps \
446 — the entity-less HTTP catch-all handler will not install, so this response can never fire",
447 );
448 }
449 if installs_pre_handler {
450 let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state();
451 // The global HTTP policy reads the request line / headers, so
452 // grant `read_headers` on top of the visitor baseline.
453 let mut caps = self.base_capabilities.clone();
454 caps.insert("read_headers".to_string());
455 install_handler(
456 mgr,
457 ENTITY_HTTP,
458 ENTITY_NAME_GLOBAL,
459 None,
460 HOOK_CMF_HTTP_REQUEST,
461 Phase::Pre,
462 Arc::new(compiled.clone()),
463 &plugin_registry,
464 &self.dispatch_cache,
465 &session_store,
466 &self.manager,
467 Some(pdp_router_arc),
468 &caps,
469 );
470 }
471
472 self.state
473 .write()
474 .unwrap_or_else(|p| p.into_inner())
475 .global_layer = Some(compiled);
476 Ok(())
477 }
478
479 fn visit_default(
480 &self,
481 _mgr: &Arc<PluginManager>,
482 entity_type: &str,
483 yaml: &serde_yaml::Value,
484 ) -> Result<(), VisitorError> {
485 let source = format!("global.defaults.{}.apl", entity_type);
486 reject_legacy_apl_keys(&source, yaml)?;
487 warn_if_response_at_unsupported_scope(yaml, &format!("global.defaults.{entity_type}"));
488 let Some(apl_block) = apl_subblock(yaml) else {
489 return Ok(());
490 };
491 warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
492 let compiled = compile_policy_block_value(&source, &apl_block)
493 .map_err(|e| Box::new(e) as VisitorError)?;
494 self.state
495 .write()
496 .unwrap_or_else(|p| p.into_inner())
497 .default_layers
498 .insert(entity_type.to_string(), compiled);
499 Ok(())
500 }
501
502 fn visit_policy_bundle(
503 &self,
504 _mgr: &Arc<PluginManager>,
505 tag: &str,
506 yaml: &serde_yaml::Value,
507 ) -> Result<(), VisitorError> {
508 let source = format!("global.policies.{}.apl", tag);
509 reject_legacy_apl_keys(&source, yaml)?;
510 warn_if_response_at_unsupported_scope(yaml, &format!("global.policies.{tag}"));
511 let Some(apl_block) = apl_subblock(yaml) else {
512 return Ok(());
513 };
514 warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
515 let compiled = compile_policy_block_value(&source, &apl_block)
516 .map_err(|e| Box::new(e) as VisitorError)?;
517 self.state
518 .write()
519 .unwrap_or_else(|p| p.into_inner())
520 .tag_layers
521 .insert(tag.to_string(), compiled);
522 Ok(())
523 }
524
525 fn visit_route(
526 &self,
527 mgr: &Arc<PluginManager>,
528 yaml: &serde_yaml::Value,
529 parsed: &RouteEntry,
530 ) -> Result<(), VisitorError> {
531 // Extract the route's APL block (if any) and the entity identity
532 // we need for annotate_route. A route without an APL block AND
533 // without inherited layers contributes nothing — skip.
534 reject_legacy_apl_keys("route", yaml)?;
535 let route_apl = apl_subblock(yaml);
536 let (entity_type, entity_names) = match entity_identity(parsed) {
537 Some(e) => e,
538 None => {
539 tracing::warn!(
540 "APL visitor: route has no tool/resource/prompt/llm match — skipping",
541 );
542 return Ok(());
543 },
544 };
545 if let Some(block) = &route_apl {
546 warn_if_global_only_key_at_nonglobal_scope(&format!("routes.{entity_type}"), block);
547 }
548 let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone());
549 let tags: Vec<String> = parsed
550 .meta
551 .as_ref()
552 .map(|m| m.tags.clone())
553 .unwrap_or_default();
554
555 // Snapshot the dispatch state once outside the per-entity loop.
556 // `visit_plugins` populated the registry before any `visit_route`
557 // call; the router + session store were finalized in `visit_global`.
558 // Routes share all three, so cloning each into an `Arc` once and
559 // handing clones to each handler is cheaper than re-reading the
560 // RwLocks per entity. Cloning `PdpRouter` is refcount bumps on each
561 // inner resolver — cheap.
562 let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state();
563
564 // Route-level denial response (transpiled `denyWith`) — parsed once;
565 // its input (`yaml`) is loop-invariant across the entity names this
566 // route matches, so hoisting avoids re-deserializing (and
567 // re-warning) once per entity. `response` is scope-local: an entity
568 // route carries only its own block, never an inherited `global` one.
569 let route_response = response_subblock(yaml, &format!("routes.{entity_type}"));
570
571 for (idx, entity_name) in entity_names.iter().enumerate() {
572 // route_key is what `DispatchCache` keys on, so it must
573 // disambiguate scoped vs unscoped routes for the same
574 // entity — otherwise two same-named annotations share one
575 // cached plan and the second's overrides leak into the first.
576 let route_key = match &scope {
577 Some(s) => format!("{}:{}@{}", entity_type, entity_name, s),
578 None => format!("{}:{}", entity_type, entity_name),
579 };
580 let state = self.state.read().unwrap_or_else(|p| p.into_inner());
581
582 // Stack least-to-most-specific. Each apply_layer call appends
583 // policy/post_policy steps and merges args/result/plugin_overrides
584 // by field; the resulting CompiledRoute represents the route's
585 // effective policy in evaluation order.
586 let mut effective = CompiledRoute::new(&route_key);
587 if let Some(layer) = state.global_layer.clone() {
588 effective.apply_layer(layer);
589 }
590 if let Some(layer) = state.default_layers.get(entity_type).cloned() {
591 effective.apply_layer(layer);
592 }
593 for tag in &tags {
594 if let Some(layer) = state.tag_layers.get(tag).cloned() {
595 effective.apply_layer(layer);
596 }
597 }
598 drop(state);
599
600 if let Some(block) = &route_apl {
601 let source = format!("routes.{}.apl", route_key);
602 let route_layer = compile_policy_block_value(&source, block)
603 .map_err(|e| Box::new(e) as VisitorError)?;
604 effective.apply_layer(route_layer);
605 }
606
607 // Route-level denial response (transpiled `denyWith`), parsed
608 // above the loop. Route scope is most-specific and inheritance
609 // was removed, so this is the only source of `response` for an
610 // entity route — a malformed or absent block leaves it `None`
611 // (host default denial), never a leaked `global` response.
612 effective.response = route_response.clone();
613
614 // Load-time lint, once per route: flag any APL `plugins:`
615 // override declared for a plugin that no policy / delegate step
616 // references. Checked on the fully-stacked `effective` route so
617 // an override consumed by an inherited (global / default / tag)
618 // policy is not falsely flagged. The overrides and referenced
619 // names are entity-independent, so the first entity is
620 // representative — guarding on `idx == 0` keeps it to one pass.
621 if idx == 0 {
622 warn_unreferenced_plugin_overrides(&effective);
623 }
624
625 // No layers contributed anything? Don't install a handler — the
626 // route falls back to cpex-core's plugin-chain execution.
627 if effective.declared_phases().is_empty() {
628 continue;
629 }
630
631 // E3.1 — plugin-mode validation for `parallel:` blocks.
632 // `apl-core::Effect::validate_parallel_purity` already rejected
633 // FieldOp / Delegate at parse time; this pass checks that every
634 // `plugin(X)` inside a `parallel:` references a plugin whose
635 // mode is safe for concurrent execution (Audit / Concurrent /
636 // FireAndForget). Sequential / Transform plugins would silently
637 // lose their mutations inside cloned branches.
638 //
639 // Looks up modes through the cpex-core PluginManager (it has
640 // the authoritative registration state). The lookup trait
641 // is `parallel_safety::PluginModeLookup`, which
642 // `PluginManager` implements.
643 if let Err(msg) =
644 crate::parallel_safety::validate_parallel_plugin_modes(&effective, mgr.as_ref())
645 {
646 let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg);
647 return Err(err_msg.into());
648 }
649
650 let route_arc = Arc::new(effective);
651
652 // Resolve the entity-specific CMF hook pair. The visitor's
653 // entity_identity() already filtered out unknown types, but
654 // hook_pair_for_entity returning None would just skip the
655 // annotation rather than crash — defense in depth.
656 let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) {
657 Some(pair) => pair,
658 None => {
659 tracing::warn!(
660 entity_type,
661 entity_name,
662 "APL visitor: no CMF hook pair for entity_type — skipping route",
663 );
664 continue;
665 },
666 };
667
668 // Install Pre + Post handlers. Each handler instance is bound to
669 // ONE phase so the executor can pick the right entry-point off
670 // the (entity_type, entity_name, scope, hook_name) key.
671 install_handler(
672 mgr,
673 entity_type,
674 entity_name,
675 scope.clone(),
676 hook_pre,
677 Phase::Pre,
678 Arc::clone(&route_arc),
679 &plugin_registry,
680 &self.dispatch_cache,
681 &session_store,
682 &self.manager,
683 Some(Arc::clone(&pdp_router_arc)),
684 &self.base_capabilities,
685 );
686 install_handler(
687 mgr,
688 entity_type,
689 entity_name,
690 scope.clone(),
691 hook_post,
692 Phase::Post,
693 route_arc,
694 &plugin_registry,
695 &self.dispatch_cache,
696 &session_store,
697 &self.manager,
698 Some(Arc::clone(&pdp_router_arc)),
699 &self.base_capabilities,
700 );
701 }
702
703 Ok(())
704 }
705}
706
707// =====================================================================
708// Helpers
709// =====================================================================
710
711#[allow(clippy::too_many_arguments)]
712fn install_handler(
713 mgr: &Arc<PluginManager>,
714 entity_type: &str,
715 entity_name: &str,
716 scope: Option<String>,
717 hook_name: &str,
718 phase: Phase,
719 route: Arc<CompiledRoute>,
720 plugin_registry: &Arc<PluginRegistry>,
721 dispatch_cache: &Arc<DispatchCache>,
722 session_store: &Arc<dyn SessionStore>,
723 manager: &Weak<PluginManager>,
724 pdp: Option<Arc<dyn PdpResolver>>,
725 base_capabilities: &std::collections::HashSet<String>,
726) {
727 // Capability gating at the synthetic-handler boundary. cpex-core's
728 // executor calls `filter_extensions(&ext, &caps)` before every
729 // handler invoke — including this one. If the synthetic handler
730 // has fewer capabilities than its downstream plugins need, the
731 // executor strips extensions on the way in (so APL predicates and
732 // downstream plugins see empty views) and rejects mutations on the
733 // way out (label / delegation appends fail monotonicity checks).
734 //
735 // Granted caps = union of every plugin's caps (with per-route
736 // overrides applied) ∪ host-supplied baseline. The baseline
737 // typically covers read-only attributes APL predicates touch
738 // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are
739 // referenced.
740 let mut capabilities = base_capabilities.clone();
741 capabilities.extend(crate::dispatch_plan::route_capability_union(
742 &route,
743 plugin_registry,
744 ));
745
746 let plugin_config = PluginConfig {
747 name: format!(
748 "apl::{}::{}::{}",
749 entity_type,
750 entity_name,
751 if phase == Phase::Pre { "pre" } else { "post" }
752 ),
753 kind: "builtin".to_string(),
754 // The annotated handler covers exactly one CMF hook name.
755 hooks: vec![hook_name.to_string()],
756 capabilities,
757 ..Default::default()
758 };
759 let mut handler = AplRouteHandler::new(
760 plugin_config.clone(),
761 route,
762 phase,
763 Arc::clone(plugin_registry),
764 Arc::clone(dispatch_cache),
765 Arc::clone(session_store),
766 manager.clone(),
767 );
768 if let Some(pdp) = pdp {
769 handler = handler.with_pdp(pdp);
770 }
771 mgr.annotate_route(
772 entity_type.to_string(),
773 entity_name.to_string(),
774 scope,
775 hook_name.to_string(),
776 Arc::new(handler),
777 plugin_config,
778 );
779}
780
781/// Pick the route's entity identities from the first non-None match
782/// field. v0: tool > resource > prompt > llm precedence. A list-form
783/// match (`tool: [a, b]`) yields one annotation per element so each
784/// request gets routed by its specific name.
785fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec<String>)> {
786 if let Some(t) = &route.tool {
787 return Some(("tool", names_of(t)));
788 }
789 if let Some(r) = &route.resource {
790 return Some(("resource", names_of(r)));
791 }
792 if let Some(p) = &route.prompt {
793 return Some(("prompt", names_of(p)));
794 }
795 if let Some(l) = &route.llm {
796 return Some(("llm", names_of(l)));
797 }
798 None
799}
800
801fn names_of(sol: &cpex_core::config::StringOrList) -> Vec<String> {
802 match sol {
803 cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()],
804 cpex_core::config::StringOrList::List(v) => v.clone(),
805 }
806}
807
808/// Warn when an APL block carries a global-only wiring key
809/// ([`GLOBAL_ONLY_NON_DSL_KEYS`]: `pdp`, `session_store`) at a scope that
810/// cannot act on it. Only [`AplConfigVisitor::visit_global`] builds PDPs
811/// and selects the session store (they are process-global CPEX wiring); a
812/// `pdp:` / `session_store:` written under a default / policy-bundle /
813/// route block is folded into the policy body and silently discarded by
814/// `compile_policy_block_value`. Surfacing it here turns that quiet no-op
815/// into an actionable signal. Applies to both the flat and `apl:`-wrapped
816/// forms — neither is processed off the global scope.
817fn warn_if_global_only_key_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) {
818 for key in GLOBAL_ONLY_NON_DSL_KEYS {
819 if apl_block.get(key).is_some() {
820 tracing::warn!(
821 scope,
822 key,
823 "APL visitor: this key is only honored under the top-level `global:` block; \
824 the declaration at this scope is ignored",
825 );
826 }
827 }
828}
829
830/// Load-time lint: warn when an APL `plugins:` override is declared for a
831/// plugin that no `plugin(...)` / `run(...)` policy step (or `delegate(...)`
832/// step) in the effective route references. The `plugins:` map only
833/// *configures* a plugin — policy steps do the *activating* — so an
834/// unreferenced override has no effect and is almost always a typo or a
835/// leftover. Inspects the fully-stacked route, so an override consumed by an
836/// inherited (global / default / tag) policy is not falsely flagged. Called
837/// once per route from `visit_route` at config-load time, never per request.
838fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) {
839 if route.plugin_overrides.is_empty() {
840 return;
841 }
842 let mut referenced: std::collections::HashSet<String> =
843 crate::dispatch_plan::collect_plugin_names(route)
844 .into_iter()
845 .collect();
846 referenced.extend(crate::dispatch_plan::collect_delegate_plugin_names(route));
847 for name in route.plugin_overrides.keys() {
848 if !referenced.contains(name) {
849 tracing::warn!(
850 plugin = %name,
851 route = %route.route_key,
852 "APL `plugins:` override declared for a plugin no policy step references \
853 — the override has no effect (the `plugins:` map configures; policy steps activate)",
854 );
855 }
856 }
857}
858
859/// APL sub-keys that are CPEX *wiring*, not policy DSL: they are honored
860/// only under the top-level `global:` block (where `visit_global` acts on
861/// them) and are stripped before the remainder is handed to
862/// `compile_policy_block_value`, which doesn't model them. Kept as a single
863/// source of truth shared by [`strip_non_dsl_keys`] and
864/// [`warn_if_global_only_key_at_nonglobal_scope`].
865const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 2] = ["pdp", "session_store"];
866
867/// Legacy APL config keys, mapped to their replacements. The flat-key path
868/// in [`apl_subblock`] only copies recognized keys into the synthetic block,
869/// so a config still using an old name would otherwise be *silently dropped*
870/// here — a fail-open for `policy` / `post_policy`. We reject them loudly.
871/// (The `apl:`-wrapped form is caught downstream by apl-core instead.)
872const RENAMED_APL_KEYS: [(&str, &str); 2] = [
873 (
874 "policy",
875 "authorization.pre_invocation (or flat pre_invocation)",
876 ),
877 (
878 "post_policy",
879 "authorization.post_invocation (or flat post_invocation)",
880 ),
881];
882
883/// Fail loudly when a section carries a renamed legacy APL key directly
884/// (flat form). Guards the fail-open where the flat-key filter in
885/// [`apl_subblock`] would otherwise drop an unrecognized `policy:` block.
886fn reject_legacy_apl_keys(scope: &str, yaml: &serde_yaml::Value) -> Result<(), VisitorError> {
887 let Some(map) = yaml.as_mapping() else {
888 return Ok(());
889 };
890 for (old, new) in RENAMED_APL_KEYS {
891 if map.contains_key(serde_yaml::Value::String(old.to_string())) {
892 return Err(format!(
893 "in `{scope}`: config field `{old}` was renamed to `{new}` — update your config",
894 )
895 .into());
896 }
897 }
898 Ok(())
899}
900
901/// Strip the global-only wiring sub-keys ([`GLOBAL_ONLY_NON_DSL_KEYS`])
902/// from an `apl:` mapping so the remainder can be handed to
903/// `compile_policy_block_value` (which doesn't model PDP / session-store
904/// declarations — those are CPEX wiring concerns). Returns a clone of the
905/// mapping with those keys removed; the original is left intact.
906fn strip_non_dsl_keys(apl_block: &serde_yaml::Value) -> serde_yaml::Value {
907 let Some(map) = apl_block.as_mapping() else {
908 return apl_block.clone();
909 };
910 let mut cloned = map.clone();
911 for key in GLOBAL_ONLY_NON_DSL_KEYS {
912 cloned.remove(serde_yaml::Value::String(key.to_string()));
913 }
914 serde_yaml::Value::Mapping(cloned)
915}
916
917/// Bridge cpex-core's JSON-based `Option<serde_json::Value>` config slot
918/// into apl-core's `Option<serde_yaml::Value>` shape. JSON is a strict
919/// subset of YAML's value model so this is round-trip safe; failure
920/// here would only happen if `serde_yaml::to_value` rejects a value
921/// `serde_json::Value` already accepted (in practice: never).
922fn plugin_config_to_yaml(cfg: &Option<serde_json::Value>) -> Option<serde_yaml::Value> {
923 cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok())
924}
925
926/// Map cpex-core's `OnError` enum onto the string shape apl-core's
927/// `PluginDeclaration` carries (kept stringly-typed there because the
928/// APL spec also allows custom orchestrator-defined error modes).
929fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String {
930 on_err.to_string()
931}
932
933/// APL keys recognized directly on a section (route / global / defaults /
934/// policy-bundle) when the `apl:` wrapper is omitted. Includes the policy
935/// DSL terms plus the global-only wiring keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]):
936/// `pdp` and `session_store` are accepted flat for parse symmetry with their
937/// `apl:`-wrapped form, but only `visit_global` acts on them — at other
938/// scopes they are inert and flagged by
939/// [`warn_if_global_only_key_at_nonglobal_scope`].
940/// `plugins` is intentionally absent here — it is shape-ambiguous (a
941/// structural plugin-ref *list* vs an apl-override *map*) and handled
942/// separately in [`apl_subblock`].
943///
944/// `authorization` is the nested `{ pre_invocation, post_invocation }`
945/// block; it is copied through verbatim and un-nested by apl-core's
946/// `compile_policy_block_value`, so nesting lives in exactly one place.
947const FLAT_APL_KEYS: [&str; 7] = [
948 "pre_invocation",
949 "post_invocation",
950 "authorization",
951 "args",
952 "result",
953 "pdp",
954 "session_store",
955];
956
957/// Pull a section's APL block out of its raw YAML.
958///
959/// The explicit `apl:` wrapper (`route -> apl -> authorization`) takes
960/// precedence. When it is absent, APL terms written directly on the
961/// section (`route -> authorization`) are accepted too: a synthetic block is
962/// assembled from the recognized [`FLAT_APL_KEYS`] present on the
963/// container, plus `plugins` when (and only when) it is a *mapping* —
964/// the apl-override shape. A structural `plugins:` *list*
965/// (`RouteEntry` / `PolicyGroup`) is left untouched. Returns `None`
966/// when neither a wrapper nor any flat APL key is present — callers
967/// treat that as "no contribution from this section" and move on.
968fn apl_subblock(yaml: &serde_yaml::Value) -> Option<serde_yaml::Value> {
969 // Explicit `apl:` wrapper wins.
970 if let Some(block) = yaml.get("apl") {
971 return if block.is_null() {
972 None
973 } else {
974 Some(block.clone())
975 };
976 }
977
978 // Fallback: APL terms written directly on the section, with no
979 // `apl:` nesting. Copy only the unambiguous APL keys so structural
980 // keys (tool / identity / defaults / ...) are never misread.
981 let mut block = serde_yaml::Mapping::new();
982 for key in FLAT_APL_KEYS {
983 if let Some(value) = yaml.get(key) {
984 block.insert(serde_yaml::Value::String(key.to_string()), value.clone());
985 }
986 }
987 // `plugins` only in its apl-override (map) shape; a list is the
988 // structural plugin-ref form and belongs to the section's own parse.
989 if let Some(value) = yaml.get("plugins") {
990 if value.is_mapping() {
991 block.insert(
992 serde_yaml::Value::String("plugins".to_string()),
993 value.clone(),
994 );
995 }
996 }
997
998 if block.is_empty() {
999 None
1000 } else {
1001 Some(serde_yaml::Value::Mapping(block))
1002 }
1003}
1004
1005/// Whether the entity-less HTTP catch-all handler (Pre-phase only) should
1006/// install for a compiled `global` layer. Gate on both Pre-phase steps
1007/// (`args` + `policy`, via [`CompiledRoute::declared_phases`]), not
1008/// `policy` alone — an operator whose `global.apl` has only an `args:`
1009/// admission block (no `policy:`) must still get the catch-all installed,
1010/// or entity-less HTTP traffic silently bypasses it entirely (fail-open by
1011/// omission).
1012fn http_catchall_should_install(compiled: &CompiledRoute) -> bool {
1013 let declared = compiled.declared_phases();
1014 declared.contains(apl_core::rules::Phase::Args)
1015 || declared.contains(apl_core::rules::Phase::Policy)
1016}
1017
1018/// `response:` is not an APL DSL term (it never enters [`apl_subblock`]'s
1019/// [`FLAT_APL_KEYS`]) — it is documented and tested as a sibling of `apl:`
1020/// (`global: { apl: {...}, response: {...} }`). But an operator who mirrors
1021/// the `pdp:` / `session_store:` convention (which *do* work identically
1022/// whether flat or nested under `apl:`) may reasonably nest `response:`
1023/// inside `apl:` too. Accept both spellings so that mistake degrades to
1024/// "the other spelling wins," not "silently dropped."
1025///
1026/// PRECEDENCE — deliberately the INVERSE of [`apl_subblock`]. `apl_subblock`
1027/// makes an explicit `apl:` wrapper win *entirely* over flat top-level keys
1028/// (for `policy:`/`pdp:`/`session_store:`); here the top-level sibling
1029/// `response:` wins over an `apl:`-nested one. This is intentional, not an
1030/// oversight: the top-level sibling is the documented, already-shipped,
1031/// tested form, so preferring it preserves backward compatibility, and the
1032/// choice can only affect the *rendered denial shape* (status/body/headers)
1033/// — never an Allow/Deny outcome. Do NOT "align" this with `apl_subblock`'s
1034/// wrapper-wins rule without a deliberate compatibility decision.
1035fn response_yaml_block(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> {
1036 yaml.get("response")
1037 .or_else(|| yaml.get("apl").and_then(|apl| apl.get("response")))
1038}
1039
1040/// Warn when a `response:` block appears at a scope that never renders it.
1041/// A custom denial response is honored only at `global` (the entity-less
1042/// HTTP path) or on a route; at `default` / policy-bundle scope it is inert
1043/// — there is no propagation path to a handler. Mirrors the existing
1044/// global-only-key lint so a misplaced `response:` fails loud, not silent.
1045fn warn_if_response_at_unsupported_scope(yaml: &serde_yaml::Value, scope: &str) {
1046 if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
1047 tracing::warn!(
1048 scope,
1049 "APL visitor: `response:` is honored only at `global` or route scope; ignoring here",
1050 );
1051 }
1052}
1053
1054/// Extract a route-level `response:` block — the transpiled `denyWith`.
1055/// cpex-core tolerates this out-of-band key on the route; here we
1056/// deserialize it into a [`DenyResponse`]. A malformed block is logged
1057/// and skipped (best-effort) rather than failing the whole config.
1058fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option<DenyResponse> {
1059 let block = response_yaml_block(yaml)?;
1060 if block.is_null() {
1061 return None;
1062 }
1063 match serde_yaml::from_value::<DenyResponse>(block.clone()) {
1064 Ok(resp) => Some(resp),
1065 Err(e) => {
1066 tracing::warn!(route = route_key, error = %e, "APL visitor: ignoring malformed route `response:` block");
1067 None
1068 },
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::{apl_subblock, http_catchall_should_install, response_subblock};
1075 use apl_core::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
1076 use apl_core::rules::{CompiledRoute, Effect};
1077
1078 fn yaml(s: &str) -> serde_yaml::Value {
1079 serde_yaml::from_str(s).expect("valid yaml")
1080 }
1081
1082 fn deny_effect() -> Effect {
1083 Effect::Deny {
1084 reason: None,
1085 code: None,
1086 }
1087 }
1088
1089 fn field_rule(field: &str) -> FieldRule {
1090 FieldRule {
1091 field: field.to_string(),
1092 pipeline: Pipeline {
1093 stages: vec![Stage::Type(TypeCheck::Str)],
1094 },
1095 source: "test".to_string(),
1096 }
1097 }
1098
1099 #[test]
1100 fn http_catchall_installs_for_args_only_global_block() {
1101 // Regression for the fail-open-by-omission gap: a `global.apl` with
1102 // only `args:` (no `policy:`) must still get the entity-less HTTP
1103 // catch-all installed. Before the fix this gated on
1104 // `!compiled.policy.is_empty()` alone, so an args-only admission
1105 // block silently disabled authorization for all entity-less HTTP
1106 // traffic.
1107 let mut route = CompiledRoute::new("global");
1108 route.args.push(field_rule("http.method"));
1109 assert!(
1110 http_catchall_should_install(&route),
1111 "an args-only global block must still install the catch-all handler"
1112 );
1113 }
1114
1115 #[test]
1116 fn http_catchall_installs_for_policy_only_global_block() {
1117 let mut route = CompiledRoute::new("global");
1118 route.policy.push(deny_effect());
1119 assert!(http_catchall_should_install(&route));
1120 }
1121
1122 #[test]
1123 fn http_catchall_does_not_install_for_empty_or_post_only_global_block() {
1124 let empty = CompiledRoute::new("global");
1125 assert!(
1126 !http_catchall_should_install(&empty),
1127 "an empty global block has nothing to evaluate; installing would be a no-op handler"
1128 );
1129
1130 let mut post_only = CompiledRoute::new("global");
1131 post_only.post_policy.push(deny_effect());
1132 assert!(
1133 !http_catchall_should_install(&post_only),
1134 "post_policy never runs on the Pre-phase-only catch-all, so it must not gate installation"
1135 );
1136 }
1137
1138 #[test]
1139 fn response_subblock_parses_denywith() {
1140 let v = yaml(
1141 "tool: \"*\"\nresponse:\n status: 403\n body: \"{\\\"error\\\":\\\"forbidden\\\"}\"\n headers:\n WWW-Authenticate: \"Bearer\"\n",
1142 );
1143 let resp = response_subblock(&v, "tool:*").expect("response present");
1144 assert_eq!(resp.status, Some(403));
1145 assert_eq!(resp.body.as_deref(), Some("{\"error\":\"forbidden\"}"));
1146 assert_eq!(
1147 resp.headers.get("WWW-Authenticate").map(String::as_str),
1148 Some("Bearer")
1149 );
1150 }
1151
1152 #[test]
1153 fn response_subblock_absent_is_none() {
1154 let v = yaml("tool: \"*\"\npolicy:\n - \"deny\"\n");
1155 assert!(response_subblock(&v, "tool:*").is_none());
1156 }
1157
1158 #[test]
1159 fn response_subblock_nested_under_apl_wrapper_is_read() {
1160 // An operator mirroring the pdp:/session_store: convention (which
1161 // work identically flat or nested under `apl:`) may nest `response:`
1162 // under `apl:` too. It must not be silently absorbed.
1163 let v =
1164 yaml("tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\n");
1165 let resp = response_subblock(&v, "tool:*").expect("nested response present");
1166 assert_eq!(resp.status, Some(401));
1167 }
1168
1169 #[test]
1170 fn response_subblock_top_level_wins_over_nested_apl_form() {
1171 let v = yaml(
1172 "tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\nresponse:\n status: 403\n",
1173 );
1174 let resp = response_subblock(&v, "tool:*").expect("response present");
1175 assert_eq!(
1176 resp.status,
1177 Some(403),
1178 "top-level sibling response takes precedence over the nested apl: form"
1179 );
1180 }
1181
1182 #[test]
1183 fn response_subblock_malformed_is_none_not_propagated() {
1184 // `status` must deserialize as a u16; a string value fails to parse.
1185 // A malformed block must be dropped (warn-only), never bubble up an
1186 // error that fails the whole config load.
1187 let v = yaml("tool: \"*\"\nresponse:\n status: \"not-a-number\"\n");
1188 assert!(
1189 response_subblock(&v, "tool:*").is_none(),
1190 "malformed response: block must be ignored, not panic or propagate an error"
1191 );
1192 }
1193
1194 #[test]
1195 fn warn_if_response_at_unsupported_scope_is_a_safe_noop() {
1196 use super::warn_if_response_at_unsupported_scope;
1197 // The helper only emits a tracing event; it must never panic whether
1198 // `response:` is present or absent at a scope that can't render it.
1199 let with_response = yaml("policy:\n - \"deny\"\nresponse:\n status: 403\n");
1200 let without = yaml("policy:\n - \"deny\"\n");
1201 warn_if_response_at_unsupported_scope(&with_response, "global.defaults.tool");
1202 warn_if_response_at_unsupported_scope(&with_response, "global.policies.some-tag");
1203 warn_if_response_at_unsupported_scope(&without, "global.defaults.tool");
1204 }
1205
1206 #[test]
1207 fn apl_wrapper_is_returned_as_is() {
1208 let v = yaml("apl:\n pre_invocation:\n - \"deny\"\n");
1209 let block = apl_subblock(&v).expect("wrapper present");
1210 assert!(
1211 block.get("pre_invocation").is_some(),
1212 "wrapper block exposes pre_invocation"
1213 );
1214 }
1215
1216 #[test]
1217 fn null_apl_wrapper_is_none() {
1218 let v = yaml("apl: null\n");
1219 assert!(
1220 apl_subblock(&v).is_none(),
1221 "explicit null apl => no contribution"
1222 );
1223 }
1224
1225 #[test]
1226 fn flat_pre_invocation_without_wrapper_is_collected() {
1227 let v = yaml("tool: get_weather\npre_invocation:\n - \"deny\"\n");
1228 let block = apl_subblock(&v).expect("flat pre_invocation recognized");
1229 assert!(
1230 block.get("pre_invocation").is_some(),
1231 "flat pre_invocation lifted into the block"
1232 );
1233 assert!(
1234 block.get("tool").is_none(),
1235 "structural keys must not leak into the apl block",
1236 );
1237 }
1238
1239 #[test]
1240 fn flat_session_store_without_wrapper_is_collected() {
1241 // A `session_store:` written directly on `global:` (no `apl:`
1242 // wrapper) must be lifted into the block so `visit_global` can act
1243 // on it — symmetric with the `apl:`-wrapped form and with `pdp:`.
1244 let v = yaml("session_store:\n kind: valkey\n endpoint: localhost:6379\n");
1245 let block = apl_subblock(&v).expect("flat session_store recognized");
1246 let ss = block
1247 .get("session_store")
1248 .expect("session_store lifted into the block");
1249 assert_eq!(
1250 ss.get("kind").and_then(|k| k.as_str()),
1251 Some("valkey"),
1252 "the session_store mapping is preserved intact",
1253 );
1254 }
1255
1256 #[test]
1257 fn flat_plugins_map_included_but_list_excluded() {
1258 // Map shape is the apl-override form → kept.
1259 let m = yaml("plugins:\n audit:\n on_error: ignore\n");
1260 let block = apl_subblock(&m).expect("plugins map is an apl term");
1261 assert!(block.get("plugins").is_some(), "plugins map is kept");
1262
1263 // List shape is structural plugin-refs → not an apl block; with no
1264 // other APL keys present, the section contributes nothing.
1265 let l = yaml("plugins:\n - audit\n");
1266 assert!(
1267 apl_subblock(&l).is_none(),
1268 "structural plugins list must not be treated as an apl block",
1269 );
1270 }
1271
1272 #[test]
1273 fn section_without_apl_terms_is_none() {
1274 let v = yaml("tool: get_weather\n");
1275 assert!(
1276 apl_subblock(&v).is_none(),
1277 "no APL terms => no contribution"
1278 );
1279 }
1280
1281 #[test]
1282 fn explicit_wrapper_wins_over_flat_keys() {
1283 let v = yaml("apl:\n pre_invocation:\n - \"allow\"\npre_invocation:\n - \"deny\"\n");
1284 let block = apl_subblock(&v).expect("wrapper present");
1285 let pre_invocation = block
1286 .get("pre_invocation")
1287 .and_then(|p| p.as_sequence())
1288 .expect("pre_invocation sequence");
1289 assert_eq!(pre_invocation.len(), 1);
1290 assert_eq!(
1291 pre_invocation[0].as_str(),
1292 Some("allow"),
1293 "the explicit apl wrapper takes precedence over flat top-level keys",
1294 );
1295 }
1296
1297 #[test]
1298 fn warn_if_global_only_key_at_nonglobal_scope_is_a_safe_noop() {
1299 use super::warn_if_global_only_key_at_nonglobal_scope;
1300 // The helper only emits a tracing event; it must never panic for
1301 // either global-only wiring key (`pdp` / `session_store`), or for
1302 // none present. (The drop semantics are exercised end-to-end; here
1303 // we just guard the helper's contract.)
1304 let with_pdp = yaml("pre_invocation:\n - \"deny\"\npdp:\n - kind: cel\n");
1305 let with_session_store =
1306 yaml("pre_invocation:\n - \"deny\"\nsession_store:\n kind: valkey\n");
1307 let without = yaml("pre_invocation:\n - \"deny\"\n");
1308 warn_if_global_only_key_at_nonglobal_scope("route", &with_pdp);
1309 warn_if_global_only_key_at_nonglobal_scope("routes.tool", &with_session_store);
1310 warn_if_global_only_key_at_nonglobal_scope("global.defaults.tool.apl", &without);
1311 }
1312
1313 #[test]
1314 fn unreferenced_plugin_override_is_detectable_and_lint_is_safe() {
1315 use super::{compile_policy_block_value, warn_unreferenced_plugin_overrides};
1316 // A route configures two plugins but its pre_invocation only activates one:
1317 // `used` is referenced by a `plugin(...)` step, `unused` is only
1318 // configured. The lint relies on `collect_plugin_names` seeing the
1319 // referenced set; verify that linkage, then that the helper runs.
1320 let block = yaml(
1321 "pre_invocation:\n - \"plugin(used)\"\n\
1322 plugins:\n used:\n on_error: ignore\n unused:\n on_error: ignore\n",
1323 );
1324 let route = compile_policy_block_value("test", &block).expect("compiles");
1325
1326 let referenced = crate::dispatch_plan::collect_plugin_names(&route);
1327 assert!(
1328 referenced.contains(&"used".to_string()),
1329 "pre_invocation step is referenced"
1330 );
1331 assert!(
1332 !referenced.contains(&"unused".to_string()),
1333 "config-only override is not a reference",
1334 );
1335 assert!(
1336 route.plugin_overrides.contains_key("unused"),
1337 "override was compiled in"
1338 );
1339
1340 // Must not panic; it warns on `unused` and stays silent on `used`.
1341 warn_unreferenced_plugin_overrides(&route);
1342 }
1343}