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
14 changes: 14 additions & 0 deletions crates/apl-cmf/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ 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";
// Violation `details` keys carrying a transpiled `denyWith` (custom HTTP
// denial response). Shared between the producer (apl-cpex route handler)
// and any consumer (host renderer / tests) so the stringly-typed contract
// stays coupled to one definition.
pub const DETAIL_HTTP_STATUS: &str = "http.status";
pub const DETAIL_HTTP_BODY: &str = "http.body";
pub const DETAIL_HTTP_HEADERS: &str = "http.headers";
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
63 changes: 63 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,52 @@ 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: deliberately NOT layered. A custom denial response is
// scope-local — the entity-less HTTP handler carries the `global`
// block directly, and an entity route carries only its own
// `response:`. Propagating it here let a `global` catch-all
// `denyWith` leak onto every inherited entity (MCP tool / llm /
// prompt / resource) denial with no way to opt back out. Callers
// set `response` explicitly at the scope that owns it.
}
}

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

#[test]
fn apply_layer_does_not_propagate_response() {
// `response` is scope-local and must never cross a layer boundary —
// a `global` catch-all denyWith must not leak onto entity routes.
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);
// base keeps its own response; the layer's is dropped.
assert_eq!(base.response.as_ref().unwrap().status, Some(401));

// A layer's response never populates an empty base either.
let mut empty = CompiledRoute::new("tool:x");
let mut with_resp = CompiledRoute::new("tool:x");
with_resp.response = Some(DenyResponse {
status: Some(418),
..Default::default()
});
empty.apply_layer(with_resp);
assert!(empty.response.is_none());
}

#[test]
fn phase_set_basic() {
let mut set = PhaseSet::new();
Expand Down
60 changes: 52 additions & 8 deletions crates/apl-cpex/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ use cpex_core::manager::PluginManager;
use cpex_core::plugin::{Plugin, PluginConfig};
use cpex_core::registry::AnyHookHandler;

use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
use apl_cmf::{extract_args, extract_result, BagBuilder};
use apl_core::evaluator::Decision;
use apl_core::plugin_decl::PluginRegistry;
use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
use apl_core::rules::CompiledRoute;
use apl_core::rules::{CompiledRoute, DenyResponse};
use apl_core::step::PdpResolver;

use crate::cmf_invoker::CmfPluginInvoker;
Expand Down Expand Up @@ -220,14 +221,16 @@ impl AnyHookHandler for AplRouteHandler {
error = %e,
"session label load failed; failing request closed"
);
let mut v = PluginViolation::new(
"session.load_failed",
"session state could not be loaded",
);
decorate_denial_response(&mut v, self.route.response.as_ref());
return Ok(Box::new(ErasedResultFields {
continue_processing: false,
modified_payload: None,
modified_extensions: None,
violation: Some(PluginViolation::new(
"session.load_failed",
"session state could not be loaded",
)),
violation: Some(v),
}));
},
};
Expand Down Expand Up @@ -408,6 +411,12 @@ impl AnyHookHandler for AplRouteHandler {
None
};

// Attach the route's transpiled `denyWith` to a violation at each
// genuine-denial site (below) via `decorate_denial_response`, rather
// than blanket-decorating whatever `violation` is set. This keeps the
// custom response off any future non-denial signal (e.g. an
// elicitation/retry/confirm violation) that must reach the host with
// its own wire shape intact.
let (mut continue_processing, mut violation) = match decision.decision {
Decision::Allow => (true, None),
Decision::Deny {
Expand All @@ -420,7 +429,9 @@ impl AnyHookHandler for AplRouteHandler {
rule_source
};
let reason = reason.unwrap_or_else(|| "access denied".to_string());
(false, Some(PluginViolation::new(code, reason)))
let mut v = PluginViolation::new(code, reason);
decorate_denial_response(&mut v, self.route.response.as_ref());
(false, Some(v))
},
};

Expand All @@ -444,10 +455,12 @@ impl AnyHookHandler for AplRouteHandler {
);
if continue_processing {
continue_processing = false;
violation = Some(PluginViolation::new(
let mut v = PluginViolation::new(
"session.persist_failed",
"session state could not be persisted",
));
);
decorate_denial_response(&mut v, self.route.response.as_ref());
violation = Some(v);
}
}

Expand All @@ -470,6 +483,37 @@ impl AnyHookHandler for AplRouteHandler {
// Helpers
// =====================================================================

/// Attach a route's transpiled `denyWith` (status/body/headers) to a
/// denial `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. `None` response leaves the host default.
///
/// Call this only from genuine-denial sites — never blanket-apply it to
/// whatever violation happens to be set, or a non-denial signal (e.g. an
/// elicitation/retry/confirm) would get stamped with a `403`-shaped
/// response the host would render instead of the intended wire signal.
fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
let Some(resp) = response else {
return;
};
if let Some(status) = resp.status {
violation
.details
.insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
}
if let Some(body) = &resp.body {
violation
.details
.insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
}
if !resp.headers.is_empty() {
violation.details.insert(
DETAIL_HTTP_HEADERS.to_string(),
serde_json::json!(resp.headers),
);
}
}

/// Rewrite the first text part of `msg` with `new_text`. If there is no
/// text part, append one. Mirrors what `MessagePayload`'s normal
/// modify-path does for single-view v0.
Expand Down
Loading
Loading