Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/apl-cmf/src/capability_namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ const TABLE: &[CapabilityEntry] = &[
prefixes: &[
BAG_HTTP_REQUEST_HEADERS_PREFIX,
BAG_HTTP_RESPONSE_HEADERS_PREFIX,
// The request line rides the same capability as headers.
BAG_HTTP_METHOD,
BAG_HTTP_PATH,
BAG_HTTP_HOST,
BAG_HTTP_SCHEME,
],
},
CapabilityEntry {
Expand Down
7 changes: 7 additions & 0 deletions crates/apl-cmf/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ pub const BAG_META_PREFIX: &str = "meta.";
pub const BAG_REQUEST_PREFIX: &str = "request.";
pub const BAG_HTTP_REQUEST_HEADERS_PREFIX: &str = "http.request_headers.";
pub const BAG_HTTP_RESPONSE_HEADERS_PREFIX: &str = "http.response_headers.";
// HTTP request line — exact keys. These ride the same `read_headers`
// capability as headers (the whole `http` slot is gated together in
// `cpex-core::extensions::filter`).
pub const BAG_HTTP_METHOD: &str = "http.method";
pub const BAG_HTTP_PATH: &str = "http.path";
pub const BAG_HTTP_HOST: &str = "http.host";
pub const BAG_HTTP_SCHEME: &str = "http.scheme";
pub const BAG_LLM_PREFIX: &str = "llm.";
pub const BAG_MCP_PREFIX: &str = "mcp.";
pub const BAG_COMPLETION_PREFIX: &str = "completion.";
Expand Down
43 changes: 43 additions & 0 deletions crates/apl-cmf/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,31 @@
// to remember the original case.
//
// Namespace:
// http.method : String (request line)
// http.path : String
// http.host : String
// http.scheme : String
// http.request_headers.<name> : String (lowercased name)
// http.response_headers.<name> : String (lowercased name)

use apl_core::AttributeBag;
use cpex_core::extensions::HttpExtension;

use crate::constants::{BAG_HTTP_HOST, BAG_HTTP_METHOD, BAG_HTTP_PATH, BAG_HTTP_SCHEME};

pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) {
if let Some(method) = &http.method {
bag.set(BAG_HTTP_METHOD.to_string(), method.clone());
}
if let Some(path) = &http.path {
bag.set(BAG_HTTP_PATH.to_string(), path.clone());
}
if let Some(host) = &http.host {
bag.set(BAG_HTTP_HOST.to_string(), host.clone());
}
if let Some(scheme) = &http.scheme {
bag.set(BAG_HTTP_SCHEME.to_string(), scheme.clone());
}
for (k, v) in &http.request_headers {
bag.set(
format!("http.request_headers.{}", k.to_lowercase()),
Expand All @@ -35,6 +53,31 @@ pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) {
mod tests {
use super::*;

#[test]
fn request_line_surfaced_in_bag() {
let http = HttpExtension {
method: Some("POST".to_string()),
path: Some("/api/widgets".to_string()),
host: Some("api.example.com".to_string()),
scheme: Some("https".to_string()),
..Default::default()
};
let mut bag = AttributeBag::new();
extract_http(&http, &mut bag);
assert_eq!(bag.get_string("http.method"), Some("POST"));
assert_eq!(bag.get_string("http.path"), Some("/api/widgets"));
assert_eq!(bag.get_string("http.host"), Some("api.example.com"));
assert_eq!(bag.get_string("http.scheme"), Some("https"));
}

#[test]
fn request_line_absent_when_unset() {
let http = HttpExtension::default();
let mut bag = AttributeBag::new();
extract_http(&http, &mut bag);
assert_eq!(bag.get_string("http.method"), None);
}

#[test]
fn headers_lowercased_in_bag() {
let mut http = HttpExtension::default();
Expand Down
3 changes: 2 additions & 1 deletion crates/apl-cmf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
// AgentExtension → agent.* (session, conversation, lineage)
// MetaExtension → meta.*
// RequestExtension → request.*
// HttpExtension → http.request_headers.*, http.response_headers.*
// HttpExtension → http.method, http.path, http.host, http.scheme,
// http.request_headers.*, http.response_headers.*
// LLMExtension → llm.*
// MCPExtension → mcp.tool.*, mcp.resource.*, mcp.prompt.*
// CompletionExtension → completion.*
Expand Down
3 changes: 2 additions & 1 deletion crates/apl-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ pub use plugin_decl::{
};
pub use route::{evaluate_post, evaluate_pre, evaluate_route, RouteDecision, RoutePayload};
pub use rules::{
CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Phase, PhaseSet, Rule,
CompareOp, CompiledRoute, Condition, DenyResponse, Effect, Expression, Literal, Phase,
PhaseSet, Rule,
};
pub use step::{
delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome,
Expand Down
55 changes: 55 additions & 0 deletions crates/apl-core/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,25 @@ impl PhaseSet {
}
}

/// Custom response to attach when a route's policy denies — the
/// transpiled form of a Kuadrant `AuthPolicy` `response.unauthorized`
/// `denyWith`. Carried on the route and surfaced on the deny outcome's
/// `details` map by the host (apl-cpex), so a host can render a custom
/// HTTP response. All fields optional; an absent block leaves the host's
/// default denial response unchanged.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DenyResponse {
/// HTTP status to use for the denial (e.g. 403, 302).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
/// Response body.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// Response headers (e.g. `Location` for a redirect, `WWW-Authenticate`).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub headers: std::collections::BTreeMap<String, String>,
}

/// Compiler output for a single route.
///
/// One `CompiledRoute` per route_key. The compiler merges global / default /
Expand Down Expand Up @@ -434,6 +453,11 @@ pub struct CompiledRoute {
/// hooks/kind/source always come from the global declaration.
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub plugin_overrides: std::collections::HashMap<String, crate::plugin_decl::PluginOverride>,

/// Custom denial response (transpiled `denyWith`). Most-specific layer
/// wins on collision. `None` leaves the host's default denial behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<DenyResponse>,
}

impl CompiledRoute {
Expand Down Expand Up @@ -515,13 +539,44 @@ impl CompiledRoute {
// plugin_overrides: HashMap::extend overwrites on key collision,
// which is exactly the more_specific-wins semantic.
self.plugin_overrides.extend(more_specific.plugin_overrides);

// response: most-specific declared block wins; absent leaves self's.
if more_specific.response.is_some() {
self.response = more_specific.response;
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn apply_layer_response_most_specific_wins() {
let mut base = CompiledRoute::new("tool:x");
base.response = Some(DenyResponse {
status: Some(401),
..Default::default()
});

let mut layer = CompiledRoute::new("tool:x");
layer.response = Some(DenyResponse {
status: Some(403),
body: Some("forbidden".to_string()),
..Default::default()
});
base.apply_layer(layer);
assert_eq!(base.response.as_ref().unwrap().status, Some(403));
assert_eq!(
base.response.as_ref().unwrap().body.as_deref(),
Some("forbidden")
);

// A layer without a response leaves the existing one intact.
base.apply_layer(CompiledRoute::new("tool:x"));
assert_eq!(base.response.as_ref().unwrap().status, Some(403));
}

#[test]
fn phase_set_basic() {
let mut set = PhaseSet::new();
Expand Down
19 changes: 19 additions & 0 deletions crates/apl-cpex/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,25 @@ impl AnyHookHandler for AplRouteHandler {
},
};

// Attach the route's transpiled `denyWith` (status/body/headers) to
// the violation's `details` map so the host can render a custom HTTP
// denial response. Carried via `details` (not new violation fields)
// to keep the violation type stable. Absent → host default response.
if let (Some(v), Some(resp)) = (violation.as_mut(), self.route.response.as_ref()) {
if let Some(status) = resp.status {
v.details
.insert("http.status".to_string(), serde_json::json!(status));
}
if let Some(body) = &resp.body {
v.details
.insert("http.body".to_string(), serde_json::json!(body));
}
if !resp.headers.is_empty() {
v.details
.insert("http.headers".to_string(), serde_json::json!(resp.headers));
}
}

// Append fail-closed (R18) with merge precedence:
// - decision Allow + append Err → flip to Deny with a
// distinguished `session.persist_failed` violation.
Expand Down
103 changes: 95 additions & 8 deletions crates/apl-cpex/src/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock, Weak};

use cpex_core::cmf::constants::{
ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT,
HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE,
HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE,
HOOK_CMF_TOOL_PRE_INVOKE,
ENTITY_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL,
HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
};
use cpex_core::config::RouteEntry;
use cpex_core::manager::PluginManager;
Expand All @@ -63,7 +63,7 @@ use cpex_core::visitor::{ConfigVisitor, VisitorError};

use apl_core::parser::compile_policy_block_value;
use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry};
use apl_core::rules::CompiledRoute;
use apl_core::rules::{CompiledRoute, DenyResponse};
use apl_core::step::{PdpFactory, PdpResolver};

use crate::dispatch_plan::DispatchCache;
Expand Down Expand Up @@ -357,7 +357,7 @@ impl ConfigVisitor for AplConfigVisitor {

fn visit_global(
&self,
_mgr: &Arc<PluginManager>,
mgr: &Arc<PluginManager>,
yaml: &serde_yaml::Value,
) -> Result<(), VisitorError> {
let Some(apl_block) = apl_subblock(yaml) else {
Expand Down Expand Up @@ -386,8 +386,50 @@ impl ConfigVisitor for AplConfigVisitor {
// `post_policy:` / `args:` / `result:` / `plugins:` (and inert
// fields it ignores), so a shallow strip on a clone is enough.
let policy_only = strip_non_dsl_keys(&apl_block);
let compiled = compile_policy_block_value("global.apl", &policy_only)
let mut compiled = compile_policy_block_value("global.apl", &policy_only)
.map_err(|e| Box::new(e) as VisitorError)?;
// A `response:` block at the global scope is the catch-all denyWith.
compiled.response = response_subblock(yaml, "global");

// Install a catch-all handler so the global policy also evaluates for
// generic (non-MCP/A2A) HTTP requests, which carry no entity (U3).
// Entity routes still stack `global` via apply_layer in visit_route;
// this is the *entity-less* evaluation path. Pre-phase only —
// authorization is an admission check, so there is no post handler.
if !compiled.policy.is_empty() {
let (plugin_registry, pdp_router_arc) = {
let state = self.state.read().unwrap_or_else(|p| p.into_inner());
(
Arc::new(state.plugin_registry.clone()),
Arc::new(state.pdp_router.clone()) as Arc<dyn PdpResolver>,
)
};
let session_store = self
.session_store
.read()
.unwrap_or_else(|p| p.into_inner())
.clone();
// The global HTTP policy reads the request line / headers, so
// grant `read_headers` on top of the visitor baseline.
let mut caps = self.base_capabilities.clone();
caps.insert("read_headers".to_string());
install_handler(
mgr,
ENTITY_HTTP,
ENTITY_NAME_GLOBAL,
None,
HOOK_CMF_HTTP_REQUEST,
Phase::Pre,
Arc::new(compiled.clone()),
&plugin_registry,
&self.dispatch_cache,
&session_store,
&self.manager,
Some(pdp_router_arc),
&caps,
);
}

self.state
.write()
.unwrap_or_else(|p| p.into_inner())
Expand Down Expand Up @@ -518,6 +560,13 @@ impl ConfigVisitor for AplConfigVisitor {
effective.apply_layer(route_layer);
}

// Route-level denial response (transpiled `denyWith`). Read from
// the route YAML alongside the APL block; cpex-core tolerates the
// out-of-band key. Route scope is most-specific, so set directly.
if let Some(resp) = response_subblock(yaml, &route_key) {
effective.response = Some(resp);
}

// Load-time lint, once per route: flag any APL `plugins:`
// override declared for a plugin that no policy / delegate step
// references. Checked on the fully-stacked `effective` route so
Expand Down Expand Up @@ -880,14 +929,52 @@ fn apl_subblock(yaml: &serde_yaml::Value) -> Option<serde_yaml::Value> {
}
}

/// Extract a route-level `response:` block — the transpiled `denyWith`.
/// cpex-core tolerates this out-of-band key on the route; here we
/// deserialize it into a [`DenyResponse`]. A malformed block is logged
/// and skipped (best-effort) rather than failing the whole config.
fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option<DenyResponse> {
let block = yaml.get("response")?;
if block.is_null() {
return None;
}
match serde_yaml::from_value::<DenyResponse>(block.clone()) {
Ok(resp) => Some(resp),
Err(e) => {
tracing::warn!(route = route_key, error = %e, "APL visitor: ignoring malformed route `response:` block");
None
},
}
}

#[cfg(test)]
mod tests {
use super::apl_subblock;
use super::{apl_subblock, response_subblock};

fn yaml(s: &str) -> serde_yaml::Value {
serde_yaml::from_str(s).expect("valid yaml")
}

#[test]
fn response_subblock_parses_denywith() {
let v = yaml(
"tool: \"*\"\nresponse:\n status: 403\n body: \"{\\\"error\\\":\\\"forbidden\\\"}\"\n headers:\n WWW-Authenticate: \"Bearer\"\n",
);
let resp = response_subblock(&v, "tool:*").expect("response present");
assert_eq!(resp.status, Some(403));
assert_eq!(resp.body.as_deref(), Some("{\"error\":\"forbidden\"}"));
assert_eq!(
resp.headers.get("WWW-Authenticate").map(String::as_str),
Some("Bearer")
);
}

#[test]
fn response_subblock_absent_is_none() {
let v = yaml("tool: \"*\"\npolicy:\n - \"deny\"\n");
assert!(response_subblock(&v, "tool:*").is_none());
}

#[test]
fn apl_wrapper_is_returned_as_is() {
let v = yaml("apl:\n policy:\n - \"deny\"\n");
Expand Down
Loading
Loading