diff --git a/CHANGELOG.md b/CHANGELOG.md index 09bc7a8d..dafab80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). > - **Fixed**: for any bug fixes. > - **Security**: in case of vulnerabilities. +## [Unreleased] + +### Added + +- **Human-in-the-loop (HIL) elicitation for APL.** A policy can pause an operation to ask a human — manager approval, a confirm, a step-up re-auth, an attestation — and resume once the human responds, without blocking the request path. Sugar verbs (`require_approval` / `confirm` / `require_step_up` / `require_attestation` / `request_info` / `require_review`) desugar to one `Step::Elicit`, resolved by name to an `ElicitationHandler` plugin exactly like `delegate(...)`. While the human hasn't answered, the phase *suspends* (`Decision` stays `Allow` with a pending bundle) and the host emits JSON-RPC `-32120` so the agent retries by echoing the elicitation id; expiry, channel error, denial, or a failed validation fail closed (default `on_error: deny`). The approval is bound to the live request args via an APL `scope:` expression (`args.amount <= 25000`) the runtime checks at resolution — never an LLM summary. Ships a working Keycloak **CIBA** channel plugin (`kind: elicitation/ciba`, in `cpex-builtins` default features). See [Elicitation]({{< relref "/docs/apl/elicitation" >}}). (#115) + +### Changed + +- **APL order comparisons now coerce numeric-looking strings.** `numeric_compare` parses string operands as `f64` for order operators (`>`, `>=`, `<`, `<=`), so `args.amount > 10000` fires when the arg arrives as the string `"25000"` — as LLM tool arguments routinely do. This affects **all** order comparisons in the engine, not just elicitation `scope:` bindings: a comparison that previously returned `false` because one side was a numeric string may now evaluate numerically. Equality (`==`) is unchanged, and genuinely non-numeric strings still don't order-compare (they yield `false`, per spec §2.3). (#115) + ## [0.2.1] - 2026-07-14 ### Added @@ -82,7 +92,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Initial release -[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.2.0...HEAD +[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.2.1...HEAD +[0.2.1]: https://github.com/contextforge-org/cpex/compare/0.2.0...0.2.1 [0.2.0]: https://github.com/contextforge-org/cpex/compare/0.1.1...0.2.0 [0.1.1]: https://github.com/contextforge-org/cpex/compare/0.1.0...0.1.1 [0.1.0]: https://github.com/contextforge-org/cpex/releases/tag/0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 1cc98b6e..1e2d62df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,7 @@ dependencies = [ "cpex-pdp-cel", "cpex-plugin-audit-logger", "cpex-plugin-delegator-oauth", + "cpex-plugin-elicitation-ciba", "cpex-plugin-identity-jwt", "cpex-plugin-pii-scanner", "cpex-session-valkey", @@ -867,6 +868,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cpex-plugin-elicitation-ciba" +version = "0.2.1" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "mockito", + "reqwest", + "serde", + "serde_json", + "tokio", + "zeroize", +] + [[package]] name = "cpex-plugin-identity-jwt" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index f282b1d3..45821e59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", + "builtins/plugins/elicitation-ciba", "builtins/pdps/cedar-direct", "builtins/pdps/cel", "builtins/session/valkey", @@ -56,6 +57,7 @@ default-members = [ "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", + "builtins/plugins/elicitation-ciba", "builtins/pdps/cedar-direct", "builtins/pdps/cel", "examples/go-demo/ffi", @@ -122,6 +124,7 @@ cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", v cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.1" } cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.1" } cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.1" } +cpex-plugin-elicitation-ciba = { path = "builtins/plugins/elicitation-ciba", version = "0.2.1" } cpex-pdp-cedar-direct = { path = "builtins/pdps/cedar-direct", version = "0.2.1" } cpex-pdp-cel = { path = "builtins/pdps/cel", version = "0.2.1" } cpex-session-valkey = { path = "builtins/session/valkey", version = "0.2.1" } diff --git a/builtins/plugins/elicitation-ciba/Cargo.toml b/builtins/plugins/elicitation-ciba/Cargo.toml new file mode 100644 index 00000000..73dbb2f1 --- /dev/null +++ b/builtins/plugins/elicitation-ciba/Cargo.toml @@ -0,0 +1,71 @@ +# Location: ./builtins/plugins/elicitation-ciba/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# cpex-plugin-elicitation-ciba — `ElicitationHandler` that drives manager +# approval (and other human-in-the-loop kinds) through OpenID Connect +# CIBA (Client-Initiated Backchannel Authentication) against Keycloak or +# any CIBA-capable OP. +# +# # Why this exists +# +# The `ElicitationHook` family defines the surface (`ElicitationPayload` +# in/out, three operations); this crate is the *backend* that actually +# reaches a human. The CIBA flow: +# +# * dispatch → POST the backchannel auth endpoint with `login_hint` +# (the approver), `binding_message` (the audited purpose), and +# `scope`; the OP pushes a decoupled approval prompt to the +# approver's authentication device and returns an `auth_req_id`. +# * check → POST the token endpoint with +# `grant_type=urn:openid:params:grant-type:ciba` + `auth_req_id`; +# `authorization_pending` / `slow_down` mean keep waiting, a token +# means approved, `access_denied` / `expired_token` are terminal. +# * validate → confirm the returned token names the expected approver. +# +# The hours-long human gap is owned by the OP, never by a handler call — +# each of the three operations is a short, synchronous HTTP round-trip. + +[package] +name = "cpex-plugin-elicitation-ciba" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "CPEX elicitation handler — OIDC CIBA human-in-the-loop approval." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +cpex-core = { workspace = true } + +# `reqwest` for the backchannel + token-endpoint POSTs. Same TLS stance +# as cpex-plugin-delegator-oauth: rustls, no native-tls/openssl link. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `base64` to decode the JWT payload segment for the approver claim +# (claims extraction only — see the validate notes in approver.rs). +base64 = "0.22" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +chrono = { workspace = true } + +# Secret-clearing wrapper for the OAuth client secret in memory. +zeroize = { version = "1.8", features = ["zeroize_derive"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +serde_json = { workspace = true } +# `mockito` stands up an HTTP server in-process so tests can assert the +# CIBA request shapes and simulate OP responses without a real Keycloak. +mockito = "1" + +[lints] +workspace = true diff --git a/builtins/plugins/elicitation-ciba/src/approver.rs b/builtins/plugins/elicitation-ciba/src/approver.rs new file mode 100644 index 00000000..705d0286 --- /dev/null +++ b/builtins/plugins/elicitation-ciba/src/approver.rs @@ -0,0 +1,584 @@ +// Location: ./builtins/plugins/elicitation-ciba/src/approver.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CibaApprover` — `HookHandler` that reaches a human +// through OIDC CIBA. One `handle` entry point dispatches on +// `ElicitationPayload::operation` to the three short, synchronous +// round-trips: +// +// * dispatch → backchannel auth POST (`login_hint` / `binding_message` +// / `scope`) → `auth_req_id` (used as the elicitation id). +// * check → token-endpoint poll (`grant_type=...:ciba` + +// `auth_req_id`) → pending / approved / denied / expired. On +// approval, extract the approver claim from the OP token and +// store *that* (never the token — see store.rs). +// * validate → cross-check the resolved approver (stored at check) +// against the expected `login_hint`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime *failures* (network, OP rejection, malformed response) → +// `PluginResult::deny(PluginViolation::new(code, reason))`, which the +// apl-cpex bridge maps to an `ElicitationError` and the apl-core +// evaluator then routes through the step's `on_error`. Normal lifecycle +// *states* (pending / approved / denied / expired) are NOT failures — +// they're returned as data on the payload via `modify_payload`. + +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use chrono::Utc; +use serde::Deserialize; +use zeroize::Zeroizing; + +use cpex_core::context::PluginContext; +use cpex_core::elicitation::{ + ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, + ElicitationStatusKind, +}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{require_https, CibaConfig}; +use crate::store::{Correlation, CorrelationStore, InMemoryCorrelationStore}; + +/// OIDC CIBA grant type for the token-endpoint poll. +const GRANT_TYPE_CIBA: &str = "urn:openid:params:grant-type:ciba"; + +/// CIBA `ElicitationHook` handler. +pub struct CibaApprover { + cfg: PluginConfig, + typed: CibaConfig, + client_secret: Zeroizing, + http: reqwest::Client, + store: Arc, +} + +impl std::fmt::Debug for CibaApprover { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CibaApprover") + .field("plugin", &self.cfg.name) + .field("backchannel_endpoint", &self.typed.backchannel_endpoint) + .field("client_id", &self.typed.client_id) + .field("client_secret", &"") + .finish() + } +} + +impl CibaApprover { + /// Build from a `PluginConfig`: parse `cfg.config` into [`CibaConfig`], + /// validate endpoints (https unless `insecure_http`), resolve the + /// client secret, and build the shared HTTP client. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg + .config + .as_ref() + .ok_or_else(|| cfg_err(&cfg.name, "requires a `config:` block".to_string()))?; + let typed: CibaConfig = serde_json::from_value(raw.clone()) + .map_err(|e| cfg_err(&cfg.name, format!("config parse failed: {e}")))?; + + for (field, url) in [ + ("backchannel_endpoint", &typed.backchannel_endpoint), + ("token_endpoint", &typed.token_endpoint), + ] { + if url.trim().is_empty() { + return Err(cfg_err(&cfg.name, format!("{field} must be non-empty"))); + } + if let Err(e) = require_https(url, typed.insecure_http) { + return Err(cfg_err(&cfg.name, format!("{field} {e}"))); + } + } + if typed.client_id.trim().is_empty() { + return Err(cfg_err( + &cfg.name, + "client_id must be non-empty".to_string(), + )); + } + + let secret = typed + .client_secret_source + .resolve() + .map_err(|e| cfg_err(&cfg.name, format!("client secret resolve failed: {e}")))?; + + let http = reqwest::Client::builder() + .timeout(typed.http_timeout()) + .build() + .map_err(|e| cfg_err(&cfg.name, format!("HTTP client build failed: {e}")))?; + + Ok(Self { + cfg, + typed, + client_secret: Zeroizing::new(secret), + http, + store: Arc::new(InMemoryCorrelationStore::new()), + }) + } + + // ---------------- dispatch ---------------- + + async fn do_dispatch(&self, payload: &ElicitationPayload) -> PluginResult { + let login_hint = payload.from(); + if login_hint.is_empty() { + return deny( + "elicitation.bad_request", + "CIBA dispatch requires a resolved approver (login_hint) — `from` \ + resolved to empty", + ); + } + + // `requested_expiry` from the step timeout (e.g. "24h"), else the + // configured default. CIBA wants seconds. + let requested_expiry = payload + .timeout() + .and_then(parse_duration_secs) + .unwrap_or(self.typed.default_requested_expiry_seconds); + let requested_expiry = requested_expiry.to_string(); + + // Keycloak constrains `binding_message`: ≤50 chars, no spaces, + // basic plain-text only (verified against Keycloak 26 — a raw + // purpose with spaces/`$`/punctuation is rejected + // `invalid_binding_message`). CIBA's binding_message is an + // anti-phishing correlation code shown on both devices, not the + // full transaction text — the canonical, human-readable `purpose` + // is recorded upstream (apl-core audit). So derive a valid code + // from it. See docs/keycloak-ciba-phase0-runbook.md. + let binding_message = payload.purpose().map(sanitize_binding_message); + + let mut form: Vec<(&str, &str)> = vec![ + ("scope", &self.typed.scope), + ("login_hint", login_hint), + ("requested_expiry", &requested_expiry), + ]; + if let Some(bm) = &binding_message { + if !bm.is_empty() { + form.push(("binding_message", bm)); + } + } + + let response = match self + .http + .post(&self.typed.backchannel_endpoint) + .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) + .form(&form) + .send() + .await + { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return deny( + "elicitation.op_timeout", + format!("CIBA backchannel POST timed out: {e}"), + ); + }, + Err(e) => { + return deny( + "elicitation.op_unreachable", + format!( + "CIBA backchannel POST to {} failed: {e}", + self.typed.backchannel_endpoint + ), + ); + }, + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return deny( + "elicitation.op_rejected", + format!("CIBA backchannel rejected ({status}): {body}"), + ); + } + + let parsed = match response.json::().await { + Ok(p) => p, + Err(e) => { + return deny( + "elicitation.bad_response", + format!("CIBA backchannel response wasn't valid JSON: {e}"), + ); + }, + }; + + // The `auth_req_id` IS the elicitation id the agent echoes on + // retry — opaque and unique, no separate id to generate. + let id = parsed.auth_req_id; + self.store.put( + &id, + Correlation { + expected_approver: login_hint.to_string(), + resolved_approver: None, + }, + ); + + let expires_at = parsed + .expires_in + .map(|secs| (Utc::now() + chrono::Duration::seconds(secs)).to_rfc3339()); + + let mut out = payload.clone(); + out.id = Some(id); + out.status = Some(ElicitationStatusKind::Pending); + out.approver = Some(login_hint.to_string()); + out.expires_at = expires_at; + PluginResult::modify_payload(out) + } + + // ---------------- check ---------------- + + async fn do_check(&self, payload: &ElicitationPayload) -> PluginResult { + let id = match payload.elicitation_id() { + Some(id) => id, + None => { + return deny( + "elicitation.bad_request", + "CIBA check requires an elicitation id", + ) + }, + }; + + // Cache short-circuit. The OP's `auth_req_id` is single-use: once a + // poll succeeds we exchange it for tokens (consuming it) and cache + // just the approver. A later check — e.g. the confirm-then-apply + // retry after a `peek` already resolved approval — must NOT re-poll + // (the spent id would come back `invalid_grant`). Replay the cached + // approved result instead; `validate` re-compares the approver. + if let Some(corr) = self.store.get(id) { + if corr.resolved_approver.is_some() { + let mut out = payload.clone(); + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Approved); + return PluginResult::modify_payload(out); + } + } + + let form: Vec<(&str, &str)> = vec![("grant_type", GRANT_TYPE_CIBA), ("auth_req_id", id)]; + + let response = match self + .http + .post(&self.typed.token_endpoint) + .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) + .form(&form) + .send() + .await + { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return deny( + "elicitation.op_timeout", + format!("CIBA token poll timed out: {e}"), + ); + }, + Err(e) => { + return deny( + "elicitation.op_unreachable", + format!( + "CIBA token poll to {} failed: {e}", + self.typed.token_endpoint + ), + ); + }, + }; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if status.is_success() { + // Approved — the OP handed back tokens (once). Extract the + // approver claim NOW and store just that string; we never keep + // the token at rest (see store.rs). `validate` then compares + // the stored expected vs resolved approver. + let parsed: TokenResponse = match serde_json::from_str(&body) { + Ok(p) => p, + Err(e) => { + return deny( + "elicitation.bad_response", + format!("CIBA token response wasn't valid JSON: {e}"), + ); + }, + }; + // Prefer the id_token (carries user claims); fall back to the + // access_token. Extract the approver claim and drop the token. + if let Some(token) = parsed.id_token.or(parsed.access_token) { + if let Some(approver) = decode_jwt_claim(&token, &self.typed.approver_claim) { + self.store.set_resolved_approver(id, approver); + } + // token dropped here — never persisted. + } + let mut out = payload.clone(); + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Approved); + return PluginResult::modify_payload(out); + } + + // Non-2xx: a standard OAuth error body drives the lifecycle. + let err_code = serde_json::from_str::(&body) + .map(|e| e.error) + .unwrap_or_default(); + + let (status_kind, outcome) = match err_code.as_str() { + // Still waiting — both mean "keep polling". + "authorization_pending" | "slow_down" => (ElicitationStatusKind::Pending, None), + "expired_token" => (ElicitationStatusKind::Expired, None), + "access_denied" => ( + ElicitationStatusKind::Resolved, + Some(ElicitationOutcomeKind::Denied), + ), + // Anything else is a genuine failure, not a lifecycle state. + _ => { + return deny( + "elicitation.op_rejected", + format!("CIBA token poll failed ({status}): {body}"), + ); + }, + }; + + let mut out = payload.clone(); + out.status = Some(status_kind); + out.outcome = outcome; + PluginResult::modify_payload(out) + } + + // ---------------- validate ---------------- + + async fn do_validate(&self, payload: &ElicitationPayload) -> PluginResult { + let id = match payload.elicitation_id() { + Some(id) => id, + None => { + return deny( + "elicitation.bad_request", + "CIBA validate requires an elicitation id", + ) + }, + }; + + let correlation = match self.store.get(id) { + Some(c) => c, + None => return invalid(payload, "unknown elicitation id"), + }; + // The approver claim was extracted from the OP token at `check` + // (provenance: it came straight from the OP over our authenticated + // TLS poll). Genuineness here = who actually approved matches who we + // asked for — a stored-vs-stored comparison, no token at rest. + let resolved = match &correlation.resolved_approver { + Some(a) => a, + None => { + return invalid( + payload, + &format!( + "elicitation has no resolved approver (no `{}` claim was \ + extracted at check, or it hasn't resolved)", + self.typed.approver_claim + ), + ) + }, + }; + + let mut out = payload.clone(); + out.approver = Some(resolved.clone()); + if *resolved == correlation.expected_approver { + out.valid = Some(true); + } else { + out.valid = Some(false); + out.reason = Some(format!( + "approver mismatch: token names `{resolved}`, expected `{}`", + correlation.expected_approver + )); + } + PluginResult::modify_payload(out) + } +} + +#[async_trait] +impl Plugin for CibaApprover { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for CibaApprover { + async fn handle( + &self, + payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + match payload.operation() { + ElicitationOp::Dispatch => self.do_dispatch(payload).await, + ElicitationOp::Check => self.do_check(payload).await, + ElicitationOp::Validate => self.do_validate(payload).await, + } + } +} + +// ----------------- wire shapes ----------------- + +/// CIBA backchannel auth response (OIDC CIBA core §7.3). +#[derive(Debug, Deserialize)] +struct BackchannelResponse { + auth_req_id: String, + #[serde(default)] + expires_in: Option, +} + +/// Token-endpoint success body (the slice we need). +#[derive(Debug, Deserialize)] +struct TokenResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + id_token: Option, +} + +/// Standard OAuth error body — `error` is the machine code +/// (`authorization_pending`, `access_denied`, …). +#[derive(Debug, Deserialize, Default)] +struct OAuthError { + #[serde(default)] + error: String, +} + +// ----------------- helpers ----------------- + +fn cfg_err(plugin: &str, msg: String) -> Box { + Box::new(PluginError::Config { + message: format!("plugin '{plugin}' (cpex-plugin-elicitation-ciba): {msg}"), + }) +} + +fn deny(code: &str, reason: impl Into) -> PluginResult { + PluginResult::deny(PluginViolation::new(code, reason.into())) +} + +/// `validate` failure that is a *verdict*, not a transport error: the +/// payload comes back with `valid = false` and a reason, and the bridge +/// reads that — the runtime then denies. (A `deny` here would instead be +/// an `on_error` failure path.) +fn invalid(payload: &ElicitationPayload, reason: &str) -> PluginResult { + let mut out = payload.clone(); + out.valid = Some(false); + out.reason = Some(reason.to_string()); + PluginResult::modify_payload(out) +} + +/// Derive a Keycloak-valid CIBA `binding_message` *cue* from a free-text +/// purpose. Keycloak requires ≤50 chars, no spaces, basic plain-text. +/// +/// `binding_message` is NOT the approver-facing description — by CIBA +/// design it is a short anti-phishing **correlation cue** shown on both +/// the requester's and the approver's devices so the human can confirm +/// they're approving the same transaction. The full, canonical +/// human-readable context is the step's `purpose` (kept verbatim in the +/// apl-core audit record) and is delivered to the approver's device by +/// the Authentication Channel + intent registry — see +/// `docs/apl-manager-approval-ciba-design.md` (§ binding_message & +/// approver-facing context). +/// +/// To keep the cue readable rather than mangled, **whitespace runs become +/// a single `-`** and every *other* disallowed character (`$`, `,`, `'`, +/// non-ASCII) is **dropped**, not dashed. So "Approve Bob's $25,000 raise +/// for Jane Smith" → "Approve-Bobs-25000-raise-for-Jane-Smith". Capped at +/// 50, trailing `-` trimmed. +fn sanitize_binding_message(purpose: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in purpose.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch); + prev_dash = false; + } else if ch.is_whitespace() && !out.is_empty() && !prev_dash { + out.push('-'); + prev_dash = true; + } + // Any other disallowed char is dropped (keeps the cue readable). + } + let capped: String = out.chars().take(50).collect(); + capped.trim_end_matches('-').to_string() +} + +/// Parse a duration string (`"30"`, `"30s"`, `"5m"`, `"24h"`, `"2d"`) +/// into seconds. Bare numbers are seconds. Returns `None` on anything +/// unparseable so the caller can fall back to a default. +fn parse_duration_secs(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (num, mult) = match s.chars().last().unwrap() { + 's' => (&s[..s.len() - 1], 1), + 'm' => (&s[..s.len() - 1], 60), + 'h' => (&s[..s.len() - 1], 3600), + 'd' => (&s[..s.len() - 1], 86_400), + c if c.is_ascii_digit() => (s, 1), + _ => return None, + }; + num.trim().parse::().ok().map(|n| n * mult) +} + +/// Decode a single string claim from a JWT's payload segment. Claims +/// extraction only — does NOT verify the signature (see the validate +/// notes). Returns `None` if the token is malformed or the claim is +/// absent / non-string. +fn decode_jwt_claim(token: &str, claim: &str) -> Option { + let payload_b64 = token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + json.get(claim)?.as_str().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_duration_units() { + assert_eq!(parse_duration_secs("30"), Some(30)); + assert_eq!(parse_duration_secs("30s"), Some(30)); + assert_eq!(parse_duration_secs("5m"), Some(300)); + assert_eq!(parse_duration_secs("24h"), Some(86_400)); + assert_eq!(parse_duration_secs("2d"), Some(172_800)); + assert_eq!(parse_duration_secs("nonsense"), None); + assert_eq!(parse_duration_secs(""), None); + } + + #[test] + fn binding_message_is_keycloak_valid() { + // The real-world purpose that Keycloak rejected raw. + let bm = sanitize_binding_message("Approve Bob's $25,000 raise for Jane Smith"); + assert!(bm.len() <= 50); + assert!(!bm.contains(' ')); + assert!(bm.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')); + // Whitespace → single `-`; `'`, `$`, `,` dropped (not dashed). + assert_eq!(bm, "Approve-Bobs-25000-raise-for-Jane-Smith"); + // Caps at 50 and trims a trailing separator. + let long = sanitize_binding_message(&"x ".repeat(60)); + assert!(long.len() <= 50); + assert!(!long.ends_with('-')); + // Empty / all-punctuation collapses to empty (no binding_message sent). + assert_eq!(sanitize_binding_message(" — "), ""); + } + + #[test] + fn decode_claim_from_jwt() { + // Build a fake JWT: header.payload.sig, payload carries + // preferred_username. Signature is irrelevant to claim extraction. + let payload = serde_json::json!({ "preferred_username": "alice", "sub": "u-1" }); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + let token = format!("aaa.{b64}.bbb"); + assert_eq!( + decode_jwt_claim(&token, "preferred_username").as_deref(), + Some("alice") + ); + assert_eq!(decode_jwt_claim(&token, "sub").as_deref(), Some("u-1")); + assert!(decode_jwt_claim(&token, "missing").is_none()); + assert!(decode_jwt_claim("not-a-jwt", "sub").is_none()); + } +} diff --git a/builtins/plugins/elicitation-ciba/src/config.rs b/builtins/plugins/elicitation-ciba/src/config.rs new file mode 100644 index 00000000..5ec71cd4 --- /dev/null +++ b/builtins/plugins/elicitation-ciba/src/config.rs @@ -0,0 +1,176 @@ +// Location: ./builtins/plugins/elicitation-ciba/src/config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `CibaApprover`. Deserializes from the +// plugin's `PluginConfig.config` field; the approver's constructor reads +// it and builds the runtime state (the shared `reqwest::Client`, the +// loaded client secret). + +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// What operators write under `plugins[].config:` in unified +/// config YAML for a CIBA elicitation handler. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CibaConfig { + /// OIDC CIBA backchannel authentication endpoint — where the + /// `dispatch` POST lands (e.g. + /// `https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth`). + pub backchannel_endpoint: String, + + /// OP token endpoint — where the `check` poll lands with + /// `grant_type=urn:openid:params:grant-type:ciba` (e.g. + /// `https://kc/realms/corp/protocol/openid-connect/token`). + pub token_endpoint: String, + + /// OAuth `client_id` identifying our gateway to the OP. The gateway + /// is the CIBA *client* that initiates the backchannel request. + pub client_id: String, + + /// Where to load the client secret from. See [`ClientSecretSource`]. + pub client_secret_source: ClientSecretSource, + + /// OAuth scopes to request on the backchannel auth request. CIBA + /// requires at least `openid`. This is the *OAuth* scope (what the + /// minted token may do), distinct from the APL `scope` arg-binding + /// expression — that one is checked by the apl-core runtime. + #[serde(default = "default_scope")] + pub scope: String, + + /// Default `requested_expiry` (seconds) for the elicitation when the + /// step doesn't carry a `timeout`. How long the approval stays + /// pollable. + #[serde(default = "default_requested_expiry_seconds")] + pub default_requested_expiry_seconds: u64, + + /// Per-call HTTP timeout. Each dispatch/check/validate is a short + /// round-trip; 5s keeps the request hot path bounded. + #[serde(default = "default_http_timeout_seconds")] + pub http_timeout_seconds: u64, + + /// Which token claim names the approver, cross-checked at `validate` + /// against the `login_hint`. Keycloak's default username claim is + /// `preferred_username`; deployments keyed on `sub` set that instead. + #[serde(default = "default_approver_claim")] + pub approver_claim: String, + + /// Explicitly allow `http://` for the endpoints. By default the + /// constructor rejects plaintext because the requests carry + /// `client_id:client_secret`. Set `true` ONLY for `http://localhost` + /// development against a docker-compose Keycloak. + #[serde(default)] + pub insecure_http: bool, +} + +/// Where the gateway's OAuth client secret is loaded from. Mirrors +/// `apl-delegator-oauth`'s source enum — env var (production), file +/// (k8s secret volume), or literal (tests/dev only). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ClientSecretSource { + EnvVar { name: String }, + File { path: PathBuf }, + Literal { secret: String }, +} + +fn default_scope() -> String { + "openid".to_string() +} + +fn default_requested_expiry_seconds() -> u64 { + 300 +} + +fn default_http_timeout_seconds() -> u64 { + 5 +} + +fn default_approver_claim() -> String { + "preferred_username".to_string() +} + +impl CibaConfig { + /// Per-call HTTP timeout as a `Duration`. + pub fn http_timeout(&self) -> Duration { + Duration::from_secs(self.http_timeout_seconds) + } +} + +impl ClientSecretSource { + /// Resolve the secret at construction time. Errors as a string the + /// caller wraps in `PluginError::Config`. + pub fn resolve(&self) -> Result { + match self { + Self::EnvVar { name } => { + std::env::var(name).map_err(|e| format!("env var '{name}' unavailable: {e}")) + }, + Self::File { path } => std::fs::read_to_string(path) + .map(|s| s.trim().to_string()) + .map_err(|e| format!("secret file '{}' unreadable: {e}", path.display())), + Self::Literal { secret } => Ok(secret.clone()), + } + } +} + +/// Reject `http://` for endpoints that carry credentials. `https://` is +/// always allowed; `http://` only when `insecure_http` is set. Other +/// schemes defer to the upstream URL parser. Returns a short fragment +/// the caller prefixes with field + plugin name. +pub(crate) fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { + let lowered = url.trim_start().to_ascii_lowercase(); + if lowered.starts_with("https://") { + return Ok(()); + } + if lowered.starts_with("http://") { + if insecure_http { + return Ok(()); + } + return Err(format!( + "must use https:// (got '{url}'). Set `insecure_http: true` to allow \ + plaintext for localhost/dev only — never production." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn config_deserializes_with_defaults() { + let raw = json!({ + "backchannel_endpoint": "https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth", + "token_endpoint": "https://kc/realms/corp/protocol/openid-connect/token", + "client_id": "cpex-gateway", + "client_secret_source": { "kind": "literal", "secret": "dev-only" }, + }); + let cfg: CibaConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.client_id, "cpex-gateway"); + assert_eq!(cfg.scope, "openid"); + assert_eq!(cfg.default_requested_expiry_seconds, 300); + assert_eq!(cfg.http_timeout_seconds, 5); + assert_eq!(cfg.approver_claim, "preferred_username"); + assert!(!cfg.insecure_http); + } + + #[test] + fn literal_secret_resolves() { + let src = ClientSecretSource::Literal { + secret: "hush".into(), + }; + assert_eq!(src.resolve().unwrap(), "hush"); + } + + #[test] + fn https_gate() { + assert!(require_https("https://kc/", false).is_ok()); + assert!(require_https("http://localhost:8080/", false).is_err()); + assert!(require_https("http://localhost:8080/", true).is_ok()); + } +} diff --git a/builtins/plugins/elicitation-ciba/src/factory.rs b/builtins/plugins/elicitation-ciba/src/factory.rs new file mode 100644 index 00000000..ea799c13 --- /dev/null +++ b/builtins/plugins/elicitation-ciba/src/factory.rs @@ -0,0 +1,59 @@ +// Location: ./builtins/plugins/elicitation-ciba/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PluginFactory` impl for the CIBA elicitation handler. Lives here +// (alongside the approver) so every host wires it up the same way. +// +// Operators declare it in CPEX YAML as: +// +// plugins: +// - name: manager-approver +// kind: elicitation/ciba +// hooks: [elicit] +// config: +// backchannel_endpoint: https://kc/realms/corp/protocol/openid-connect/ext/ciba/auth +// token_endpoint: https://kc/realms/corp/protocol/openid-connect/token +// client_id: cpex-gateway +// client_secret_source: { kind: env, name: CIBA_CLIENT_SECRET } +// +// Then policy routes name it: `require_approval(manager-approver, from: claim.manager, ...)`. +// +// Hosts call +// `mgr.register_factory("elicitation/ciba", Box::new(CibaApproverFactory))` +// before loading config. + +use std::sync::Arc; + +use cpex_core::{ + elicitation::{ElicitationHook, HOOK_ELICIT}, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::CibaApprover; + +/// The plugin `kind:` string operators write in CPEX YAML to declare a +/// CIBA elicitation handler. +pub const KIND: &str = "elicitation/ciba"; + +/// Factory for `kind: elicitation/ciba` plugins. Instantiates a +/// `CibaApprover` from the `config:` block and registers it on the +/// `elicit` hook. +pub struct CibaApproverFactory; + +impl PluginFactory for CibaApproverFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let approver = Arc::new(CibaApprover::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new(Arc::clone( + &approver, + ))); + Ok(PluginInstance { + plugin: approver, + handlers: vec![(HOOK_ELICIT, handler)], + }) + } +} diff --git a/builtins/plugins/elicitation-ciba/src/lib.rs b/builtins/plugins/elicitation-ciba/src/lib.rs new file mode 100644 index 00000000..ffb4023a --- /dev/null +++ b/builtins/plugins/elicitation-ciba/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./builtins/plugins/elicitation-ciba/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// cpex-plugin-elicitation-ciba — `ElicitationHandler` backed by OIDC CIBA +// (Client-Initiated Backchannel Authentication). +// +// The host registers this handler against the `elicit` hook; APL +// policies select it by name (`require_approval(manager-approver, ...)`). +// The apl-cpex bridge invokes it once per dispatch / check / validate +// across the elicitation's lifetime; this crate turns each into the +// corresponding CIBA round-trip against the configured OP (Keycloak by +// default). +// +// See the module docs for the per-operation flow: +// * [`config`] — typed `config:` block. +// * [`store`] — in-flight correlation store (in-memory v1). +// * [`approver`] — the handler + CIBA HTTP. +// * [`factory`] — `kind: elicitation/ciba` registration. + +pub mod approver; +pub mod config; +pub mod factory; +pub mod store; + +pub use approver::CibaApprover; +pub use config::{CibaConfig, ClientSecretSource}; +pub use factory::{CibaApproverFactory, KIND}; +pub use store::{Correlation, CorrelationStore, InMemoryCorrelationStore}; diff --git a/builtins/plugins/elicitation-ciba/src/store.rs b/builtins/plugins/elicitation-ciba/src/store.rs new file mode 100644 index 00000000..12dff4fb --- /dev/null +++ b/builtins/plugins/elicitation-ciba/src/store.rs @@ -0,0 +1,138 @@ +// Location: ./builtins/plugins/elicitation-ciba/src/store.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Correlation store — maps an elicitation id (the CIBA `auth_req_id`, +// which the agent echoes on retry) to the state the handler needs across +// the dispatch → check → validate lifetime: who the *expected* approver is +// (`login_hint`, set at dispatch) and, once `check` sees a successful poll, +// who *actually* approved (the approver claim extracted from the OP token). +// +// # Why we store the extracted claim, not the token +// +// CIBA hands the token back exactly once (a second poll on the same +// `auth_req_id` fails), and `validate` runs on a later request than +// `check` — so the relevant fact must be carried across. We extract the +// approver claim at `check` and store *that string*, then drop the token. +// `validate` compares the two stored strings (expected vs resolved); it +// never needs the token. This keeps a **bearer credential out of the +// store at rest** — so even a leaked/co-tenant store reveals only "who +// approved what," never a usable token. (The `require_step_up` path, +// which forwards the CIBA token, is separate and does not use this store.) +// +// v1 is in-process (`InMemoryCorrelationStore`). That survives retries +// within one gateway process — enough for a single-node demo. The trait +// is the seam for a Valkey-backed store (cross-node / cross-restart) — +// deferred; when added, the CIBA store should use its own instance or an +// ACL-scoped user so it is isolated from the session-store keyspace. + +use std::collections::HashMap; +use std::sync::Mutex; + +/// State tracked per in-flight elicitation. +#[derive(Debug, Clone)] +pub struct Correlation { + /// The approver the backchannel request named (`login_hint`), set at + /// dispatch. `validate` cross-checks the resolved approver against it. + pub expected_approver: String, + /// Who actually approved — the approver claim (e.g. `preferred_username`) + /// extracted from the OP token at `check`. `None` until a successful + /// poll resolves it. We keep the **extracted claim, not the token**, so + /// no bearer credential sits in the store at rest. + pub resolved_approver: Option, +} + +/// Storage for in-flight CIBA correlations, keyed by elicitation id. +pub trait CorrelationStore: Send + Sync { + /// Record a freshly dispatched elicitation. + fn put(&self, id: &str, correlation: Correlation); + /// Read the current state for an id, if present. + fn get(&self, id: &str) -> Option; + /// Record who approved (the extracted claim) against an existing + /// correlation. No-op if the id is unknown. + fn set_resolved_approver(&self, id: &str, approver: String); +} + +/// In-process correlation store. Thread-safe; the plugin instance is +/// shared across requests, so this map persists across an agent's retries +/// within one gateway process. +#[derive(Debug, Default)] +pub struct InMemoryCorrelationStore { + inner: Mutex>, +} + +impl InMemoryCorrelationStore { + pub fn new() -> Self { + Self::default() + } +} + +impl CorrelationStore for InMemoryCorrelationStore { + fn put(&self, id: &str, correlation: Correlation) { + self.inner + .lock() + .unwrap_or_else(|p| p.into_inner()) + .insert(id.to_string(), correlation); + } + + fn get(&self, id: &str) -> Option { + self.inner + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(id) + .cloned() + } + + fn set_resolved_approver(&self, id: &str, approver: String) { + if let Some(c) = self + .inner + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get_mut(id) + { + c.resolved_approver = Some(approver); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn put_get_roundtrip() { + let store = InMemoryCorrelationStore::new(); + store.put( + "req-1", + Correlation { + expected_approver: "alice".into(), + resolved_approver: None, + }, + ); + let c = store.get("req-1").expect("present"); + assert_eq!(c.expected_approver, "alice"); + assert!(c.resolved_approver.is_none()); + assert!(store.get("missing").is_none()); + } + + #[test] + fn set_resolved_approver_records_on_existing() { + let store = InMemoryCorrelationStore::new(); + store.put( + "req-1", + Correlation { + expected_approver: "alice".into(), + resolved_approver: None, + }, + ); + store.set_resolved_approver("req-1", "alice".into()); + assert_eq!( + store.get("req-1").unwrap().resolved_approver.as_deref(), + Some("alice") + ); + // Unknown id is a silent no-op. + store.set_resolved_approver("missing", "x".into()); + assert!(store.get("missing").is_none()); + } +} diff --git a/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs b/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs new file mode 100644 index 00000000..49828a7f --- /dev/null +++ b/builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs @@ -0,0 +1,281 @@ +// Location: ./builtins/plugins/elicitation-ciba/tests/ciba_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Integration tests for the CIBA elicitation handler against a mock OP +// (mockito). Exercises the real request shapes and the lifecycle mapping +// for dispatch → check → validate without a live Keycloak. + +use std::collections::HashSet; + +use base64::Engine; +use serde_json::json; + +use cpex_core::context::PluginContext; +use cpex_core::elicitation::{ + ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, ElicitationStatusKind, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::HookHandler; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use cpex_plugin_elicitation_ciba::CibaApprover; + +// --------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------- + +fn approver(server_url: &str) -> CibaApprover { + let cfg = PluginConfig { + name: "manager-approver".to_string(), + kind: "elicitation/ciba".to_string(), + description: None, + author: None, + version: None, + hooks: vec!["elicit".to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: HashSet::new(), + tags: Vec::new(), + conditions: Vec::new(), + config: Some(json!({ + "backchannel_endpoint": format!("{server_url}/ciba/auth"), + "token_endpoint": format!("{server_url}/token"), + "client_id": "cpex-gateway", + "client_secret_source": { "kind": "literal", "secret": "shh" }, + // mockito serves http:// — allow it for the test only. + "insecure_http": true, + })), + }; + CibaApprover::new(cfg).expect("construct approver") +} + +async fn run(approver: &CibaApprover, payload: ElicitationPayload) -> ElicitationPayload { + let ext = Extensions::default(); + let mut ctx = PluginContext::new(); + let result = approver.handle(&payload, &ext, &mut ctx).await; + assert!( + result.continue_processing, + "handler denied: {:?}", + result.violation + ); + result + .modified_payload + .expect("handler returned an ElicitationPayload") +} + +/// Build a fake id_token whose payload carries `preferred_username`. +fn fake_id_token(username: &str) -> String { + let payload = json!({ "preferred_username": username, "sub": "u-1" }); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + format!("aaa.{b64}.sig") +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +#[tokio::test] +async fn dispatch_posts_backchannel_and_returns_auth_req_id() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("POST", "/ciba/auth") + // Assert the CIBA request shape: login_hint + binding_message. + // The purpose "Approve raise" is sanitized to a Keycloak-valid, + // space-free correlation code before it goes on the wire. + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("login_hint".into(), "alice@corp.com".into()), + mockito::Matcher::UrlEncoded("binding_message".into(), "Approve-raise".into()), + mockito::Matcher::UrlEncoded("scope".into(), "openid".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "auth_req_id": "REQ-123", "expires_in": 300, "interval": 5 }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + let payload = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com") + .with_purpose("Approve raise"); + let out = run(&app, payload).await; + + m.assert_async().await; + assert_eq!(out.id.as_deref(), Some("REQ-123")); + assert_eq!(out.status, Some(ElicitationStatusKind::Pending)); + assert_eq!(out.approver.as_deref(), Some("alice@corp.com")); + assert!(out.expires_at.is_some()); +} + +#[tokio::test] +async fn check_authorization_pending_maps_to_pending() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("POST", "/token") + .match_body(mockito::Matcher::UrlEncoded( + "auth_req_id".into(), + "REQ-123".into(), + )) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(json!({ "error": "authorization_pending" }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") + .with_elicitation_id("REQ-123"); + let out = run(&app, payload).await; + + m.assert_async().await; + assert_eq!(out.status, Some(ElicitationStatusKind::Pending)); + assert!(out.outcome.is_none()); +} + +#[tokio::test] +async fn check_success_maps_to_resolved_approved() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ "access_token": "at", "id_token": fake_id_token("alice@corp.com") }) + .to_string(), + ) + .create_async() + .await; + + let app = approver(&server.url()); + let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") + .with_elicitation_id("REQ-123"); + let out = run(&app, payload).await; + + assert_eq!(out.status, Some(ElicitationStatusKind::Resolved)); + assert_eq!(out.outcome, Some(ElicitationOutcomeKind::Approved)); +} + +#[tokio::test] +async fn check_access_denied_maps_to_resolved_denied() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/token") + .with_status(400) + .with_body(json!({ "error": "access_denied" }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") + .with_elicitation_id("REQ-123"); + let out = run(&app, payload).await; + + assert_eq!(out.status, Some(ElicitationStatusKind::Resolved)); + assert_eq!(out.outcome, Some(ElicitationOutcomeKind::Denied)); +} + +#[tokio::test] +async fn check_expired_token_maps_to_expired() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/token") + .with_status(400) + .with_body(json!({ "error": "expired_token" }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + let payload = ElicitationPayload::new(ElicitationOp::Check, "approval", "") + .with_elicitation_id("REQ-123"); + let out = run(&app, payload).await; + + assert_eq!(out.status, Some(ElicitationStatusKind::Expired)); +} + +#[tokio::test] +async fn full_flow_dispatch_check_validate_approves() { + // One approver instance across all three ops, so the in-memory + // correlation store carries the expected approver + cached token. + let mut server = mockito::Server::new_async().await; + let _auth = server + .mock("POST", "/ciba/auth") + .with_status(200) + .with_body(json!({ "auth_req_id": "REQ-9", "expires_in": 300 }).to_string()) + .create_async() + .await; + let _tok = server + .mock("POST", "/token") + .with_status(200) + .with_body(json!({ "id_token": fake_id_token("alice@corp.com") }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + + // 1. dispatch — login_hint = the resolved approver. + let d = run( + &app, + ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com") + .with_purpose("Approve raise"), + ) + .await; + let id = d.id.clone().expect("dispatch id"); + + // 2. check — approved. + let c = run( + &app, + ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id), + ) + .await; + assert_eq!(c.outcome, Some(ElicitationOutcomeKind::Approved)); + + // 3. validate — token's preferred_username matches the login_hint. + let v = run( + &app, + ElicitationPayload::new(ElicitationOp::Validate, "approval", "").with_elicitation_id(&id), + ) + .await; + assert_eq!(v.valid, Some(true)); + assert_eq!(v.approver.as_deref(), Some("alice@corp.com")); +} + +#[tokio::test] +async fn validate_rejects_approver_mismatch() { + let mut server = mockito::Server::new_async().await; + let _auth = server + .mock("POST", "/ciba/auth") + .with_status(200) + .with_body(json!({ "auth_req_id": "REQ-x", "expires_in": 300 }).to_string()) + .create_async() + .await; + // The token comes back naming a DIFFERENT user than the login_hint. + let _tok = server + .mock("POST", "/token") + .with_status(200) + .with_body(json!({ "id_token": fake_id_token("mallory@corp.com") }).to_string()) + .create_async() + .await; + + let app = approver(&server.url()); + let d = run( + &app, + ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@corp.com"), + ) + .await; + let id = d.id.unwrap(); + let _ = run( + &app, + ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id), + ) + .await; + let v = run( + &app, + ElicitationPayload::new(ElicitationOp::Validate, "approval", "").with_elicitation_id(&id), + ) + .await; + + assert_eq!(v.valid, Some(false)); + assert!(v.reason.unwrap().contains("approver mismatch")); +} diff --git a/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs b/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs new file mode 100644 index 00000000..ad644875 --- /dev/null +++ b/builtins/plugins/elicitation-ciba/tests/live_keycloak.rs @@ -0,0 +1,115 @@ +// Location: ./builtins/plugins/elicitation-ciba/tests/live_keycloak.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Phase 0 verification — runs the *automatable* half of the CIBA flow +// (dispatch → check-pending) against a REAL Keycloak, so "Phase 0 +// verified" is a repeatable test, not just the manual runbook +// (`docs/keycloak-ciba-phase0-runbook.md`). +// +// The approval step (§5.3 of the runbook) is decoupled and human-driven, +// so it can't be asserted here — but dispatch + the first poll exercise +// the realm/client/auth config end-to-end, which is what usually breaks. +// +// `#[ignore]` by default. To run against a Keycloak configured per the +// runbook: +// +// CIBA_BACKCHANNEL_ENDPOINT=http://localhost:8080/realms/corp/protocol/openid-connect/ext/ciba/auth \ +// CIBA_TOKEN_ENDPOINT=http://localhost:8080/realms/corp/protocol/openid-connect/token \ +// CIBA_CLIENT_ID=cpex-gateway \ +// CIBA_CLIENT_SECRET= \ +// CIBA_LOGIN_HINT=alice \ +// cargo test -p cpex-plugin-elicitation-ciba --test live_keycloak -- --ignored --nocapture + +use std::collections::HashSet; + +use serde_json::json; + +use cpex_core::context::PluginContext; +use cpex_core::elicitation::{ElicitationOp, ElicitationPayload, ElicitationStatusKind}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::HookHandler; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use cpex_plugin_elicitation_ciba::CibaApprover; + +/// Read a required env var, or `None` (so the test skips cleanly). +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +#[tokio::test] +#[ignore = "requires a live Keycloak configured per docs/keycloak-ciba-phase0-runbook.md"] +async fn live_dispatch_then_pending() { + let (Some(backchannel), Some(token), Some(client_id), Some(secret), Some(login_hint)) = ( + env("CIBA_BACKCHANNEL_ENDPOINT"), + env("CIBA_TOKEN_ENDPOINT"), + env("CIBA_CLIENT_ID"), + env("CIBA_CLIENT_SECRET"), + env("CIBA_LOGIN_HINT"), + ) else { + eprintln!("SKIP: set CIBA_BACKCHANNEL_ENDPOINT / _TOKEN_ENDPOINT / _CLIENT_ID / _CLIENT_SECRET / _LOGIN_HINT"); + return; + }; + + let insecure = backchannel.starts_with("http://") || token.starts_with("http://"); + let cfg = PluginConfig { + name: "manager-approver".to_string(), + kind: "elicitation/ciba".to_string(), + description: None, + author: None, + version: None, + hooks: vec!["elicit".to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: HashSet::new(), + tags: Vec::new(), + conditions: Vec::new(), + config: Some(json!({ + "backchannel_endpoint": backchannel, + "token_endpoint": token, + "client_id": client_id, + "client_secret_source": { "kind": "literal", "secret": secret }, + "insecure_http": insecure, + })), + }; + let approver = CibaApprover::new(cfg).expect("construct approver"); + let ext = Extensions::default(); + + // 1. dispatch → backchannel auth request (runbook §5.1). + let dispatch = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", &login_hint) + .with_purpose("Phase 0 verification — please ignore"); + let mut ctx = PluginContext::new(); + let out = approver.handle(&dispatch, &ext, &mut ctx).await; + assert!( + out.continue_processing, + "dispatch denied: {:?}", + out.violation + ); + let dispatched = out.modified_payload.expect("dispatch payload"); + let id = dispatched + .id + .clone() + .expect("Keycloak returned an auth_req_id"); + assert_eq!(dispatched.status, Some(ElicitationStatusKind::Pending)); + assert_eq!(dispatched.approver.as_deref(), Some(login_hint.as_str())); + eprintln!("dispatch OK — auth_req_id = {id}"); + + // 2. check → token poll before approval (runbook §5.2). Without a + // completed decoupled approval this must report Pending. + let check = + ElicitationPayload::new(ElicitationOp::Check, "approval", "").with_elicitation_id(&id); + let mut ctx = PluginContext::new(); + let out = approver.handle(&check, &ext, &mut ctx).await; + assert!(out.continue_processing, "check denied: {:?}", out.violation); + let checked = out.modified_payload.expect("check payload"); + assert_eq!( + checked.status, + Some(ElicitationStatusKind::Pending), + "expected authorization_pending before approval; got {:?}", + checked.status + ); + eprintln!("check OK — status = Pending (no approval yet, as expected)"); +} diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs index baa73b3d..ca2c4cdf 100644 --- a/crates/apl-cmf/tests/end_to_end.rs +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use apl_cmf::BagBuilder; use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, - NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, - PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, ElicitationInvoker, + NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, + PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, }; use async_trait::async_trait; use cpex_core::extensions::{ @@ -35,6 +35,9 @@ fn plugins() -> Arc { fn delegations() -> Arc { Arc::new(NoopDelegationInvoker) } +fn elicitations() -> Arc { + Arc::new(NoopElicitationInvoker) +} // HR route from unified-config-proposal.md §Example 1. const HR_ROUTE_YAML: &str = r#" @@ -175,6 +178,7 @@ async fn alice_full_route_through_cmf_bridge() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -211,6 +215,7 @@ async fn mallory_gets_both_fields_redacted_through_cmf_bridge() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -244,6 +249,7 @@ async fn deep_delegation_denies_through_cmf_bridge() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert!(matches!(r.decision, Decision::Deny { .. })); @@ -281,6 +287,7 @@ routes: &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -318,6 +325,7 @@ async fn anonymous_user_denied_at_authenticated_check() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert!(matches!(r.decision, Decision::Deny { .. })); diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs index bcfd1889..62eafe7c 100644 --- a/crates/apl-core/src/evaluator.rs +++ b/crates/apl-core/src/evaluator.rs @@ -143,13 +143,19 @@ fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool { } fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool { - let (a, b) = match (attr, lit) { - (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64), - (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b), - (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64), - (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b), - // Non-numeric operands: order operators don't apply → false (spec §2.3). - _ => return false, + // Coerce both operands to f64 for the order comparison. Numeric-looking + // strings are coerced — LLM tool arguments routinely arrive as strings + // (e.g. `"amount": "25000"`), and a policy author writing + // `args.amount > 10000` plainly means a numeric comparison. A string + // that doesn't parse as a number is genuinely non-numeric → false + // (order operators don't apply, spec §2.3). + let a = match coerce_f64_attr(attr) { + Some(a) => a, + None => return false, + }; + let b = match coerce_f64_lit(lit) { + Some(b) => b, + None => return false, }; match op { CompareOp::Gt => a > b, @@ -160,6 +166,46 @@ fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool } } +/// Coerce a bag attribute to `f64` for an order comparison: numbers pass +/// through; a string is parsed (numeric-looking strings only); anything +/// else is non-numeric. +fn coerce_f64_attr(attr: &AttributeValue) -> Option { + match attr { + AttributeValue::Int(a) => Some(*a as f64), + AttributeValue::Float(a) => Some(*a), + AttributeValue::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +/// Coerce a literal to `f64` for an order comparison. Same rules as +/// [`coerce_f64_attr`] so `args.x > "10"` works symmetrically. +fn coerce_f64_lit(lit: &Literal) -> Option { + match lit { + Literal::Int(b) => Some(*b as f64), + Literal::Float(b) => Some(*b), + Literal::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +/// Heuristic: does `s` look like a bag attribute reference (e.g. +/// `claim.manager`, `user.sub`) rather than a literal identity (e.g. +/// `alice@corp.com`, a bare username)? Used to decide whether an +/// unresolved elicitation `from` should fail closed (an attribute that +/// didn't resolve) or pass through as a literal. +/// +/// A reference is a dotted path of identifier characters only — starts +/// with a letter and contains only `[A-Za-z0-9_.]` with at least one dot. +/// Literals like emails (contain `@`) or display names (contain spaces) +/// are excluded, so they still pass through verbatim. +fn looks_like_attribute_ref(s: &str) -> bool { + s.contains('.') + && s.starts_with(|c: char| c.is_ascii_alphabetic()) + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + // ===================================================================== // Async effect evaluator (policy: / post_policy: walks Vec) // ===================================================================== @@ -197,12 +243,14 @@ pub async fn evaluate_effects( pdp: &Arc, plugins: &Arc, delegations: &Arc, + elicitations: &Arc, phase: crate::step::DispatchPhase, payload: &mut crate::route::RoutePayload, ) -> StepsEvaluation { let mut taints: Vec = Vec::new(); let mut args_modified = false; let mut result_modified = false; + let mut pending: Option = None; for effect in effects { // Each top-level effect runs against the shared mutable state. // `Effect::When` / `Effect::Pdp` handle their own internal @@ -218,6 +266,7 @@ pub async fn evaluate_effects( pdp, plugins, delegations, + elicitations, phase, &mut taints, &mut args_modified, @@ -230,6 +279,13 @@ pub async fn evaluate_effects( EffectOutcome::Halt(decision) => { return StepsEvaluation::deny(decision, taints, args_modified, result_modified); }, + EffectOutcome::Pending(bundle) => { + // Suspend the phase: stop walking (sequential elicitation), + // carry the bundle out. Decision stays Allow — the host + // gates on `pending` being set, not on a deny. + pending = Some(bundle); + break; + }, } } StepsEvaluation { @@ -237,6 +293,7 @@ pub async fn evaluate_effects( taints, args_modified, result_modified, + pending, } } @@ -256,6 +313,11 @@ pub struct StepsEvaluation { pub taints: Vec, pub args_modified: bool, pub result_modified: bool, + /// Set when a phase suspended on an unresolved elicitation. `Some` + /// means "do not forward — emit `-32120` with this bundle." `decision` + /// is `Allow` in that case (nothing denied); the host gates forwarding + /// on `pending.is_none()`. See [`crate::step::PendingElicitation`]. + pub pending: Option, } impl StepsEvaluation { @@ -270,6 +332,7 @@ impl StepsEvaluation { taints, args_modified, result_modified, + pending: None, } } } @@ -286,6 +349,13 @@ enum EffectOutcome { /// Effect produced a Deny decision — caller halts the rest of the /// surrounding list, the rest of the phase, and the route. Halt(Decision), + /// Effect dispatched an elicitation that hasn't resolved yet — the + /// phase *suspends*. Like `Halt` it short-circuits the surrounding + /// list, but it is NOT a deny: the carried bundle propagates up to + /// the host, which emits `-32120` (retry) instead of forwarding. The + /// phase decision stays `Allow`; pending being non-empty is what + /// blocks the forward (see [`PendingElicitation`]). + Pending(crate::step::PendingElicitation), } /// Run a single effect against the evaluator's state. Called by both @@ -307,6 +377,7 @@ async fn dispatch_effect( pdp: &Arc, plugins: &Arc, delegations: &Arc, + elicitations: &Arc, phase: crate::step::DispatchPhase, taints: &mut Vec, args_modified: &mut bool, @@ -414,6 +485,8 @@ async fn dispatch_effect( } }, + Effect::Elicit(elicit_step) => dispatch_elicitation(elicit_step, bag, elicitations).await, + Effect::Taint { label, scopes } => { // Emit the taint into the phase's accumulator so it flows // into `RouteDecision.taints`. Apl-cpex's invoker handles @@ -456,6 +529,7 @@ async fn dispatch_effect( pdp, plugins, delegations, + elicitations, phase, taints, args_modified, @@ -465,7 +539,8 @@ async fn dispatch_effect( .await { EffectOutcome::Continue => continue, - halt @ EffectOutcome::Halt(_) => return halt, + // Halt (deny) and Pending (suspend) both propagate up. + other => return other, } } EffectOutcome::Continue @@ -483,6 +558,7 @@ async fn dispatch_effect( pdp, plugins, delegations, + elicitations, phase, taints, payload, @@ -509,6 +585,7 @@ async fn dispatch_effect( pdp, plugins, delegations, + elicitations, phase, taints, args_modified, @@ -518,7 +595,8 @@ async fn dispatch_effect( .await { EffectOutcome::Continue => continue, - halt @ EffectOutcome::Halt(_) => return halt, + // Halt (deny) and Pending (suspend) both propagate up. + other => return other, } } EffectOutcome::Continue @@ -544,6 +622,7 @@ async fn dispatch_effect( pdp, plugins, delegations, + elicitations, phase, taints, args_modified, @@ -553,7 +632,8 @@ async fn dispatch_effect( .await { EffectOutcome::Continue => continue, - halt @ EffectOutcome::Halt(_) => return halt, + // Halt (deny) and Pending (suspend) propagate. + other => return other, } } EffectOutcome::Continue @@ -564,23 +644,26 @@ async fn dispatch_effect( // upgrade the deny to allow — if reactions // walked clean, the PDP's original deny stands. for inner in on_deny { - if let EffectOutcome::Halt(reaction_decision) = - Box::pin(dispatch_effect( - inner, - fallback_source, - bag, - pdp, - plugins, - delegations, - phase, - taints, - args_modified, - result_modified, - payload, - )) - .await + match Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + elicitations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await { - return EffectOutcome::Halt(reaction_decision); + EffectOutcome::Continue => {}, + // A reaction can override the deny reason; + // a suspended reaction (Pending) surfaces. + other => return other, } } EffectOutcome::Halt(deny) @@ -595,6 +678,185 @@ async fn dispatch_effect( } } +/// Drive one [`Effect::Elicit`] step: dispatch on first arrival, check +/// status on every pass, and on an approved-and-genuine response apply +/// the runtime's `scope`-over-args sufficiency check before allowing the +/// phase to continue. See `docs/apl-manager-approval-ciba-design.md`. +/// +/// Failure handling follows the step's `on_error` (default `deny`, +/// fail-closed). An explicit human *denial* always halts regardless of +/// `on_error` — `on_error` governs channel/validation *failures*, not a +/// valid "no" from the approver. +/// +/// While the human hasn't answered, the phase *suspends*: the step yields +/// an [`EffectOutcome::Pending`] carrying the bundle the host turns into a +/// JSON-RPC `-32120` (retry) instead of forwarding. The bag also records +/// `elicitation.status = pending` so the host/audit can see the in-flight +/// state. +async fn dispatch_elicitation( + step: &crate::step::ElicitStep, + bag: &mut AttributeBag, + elicitations: &Arc, +) -> EffectOutcome { + use crate::step::{elicitation_bag_keys as bk, ElicitationOutcome, ElicitationStatus}; + + // `on_error` applies to *failures* (channel error, invalid response, + // expiry, still-pending) — not to a genuine denial. + let on_error_continue = step + .on_error + .as_deref() + .unwrap_or("deny") + .eq_ignore_ascii_case("continue"); + let fail = |reason: String| -> EffectOutcome { + if on_error_continue { + EffectOutcome::Continue + } else { + EffectOutcome::Halt(Decision::Deny { + reason: Some(reason), + rule_source: step.source.clone(), + }) + } + }; + + // First arrival vs. retry: the agent echoes `elicitation.id` on + // retry. Absent → first arrival: dispatch and record the pending + // bundle in the bag. + // + // Resolve `from` against the bag (e.g. `claim.manager` → the + // manager's identity). A `from` written as a literal identity (e.g. + // `alice@corp.com`) that isn't a bag key falls through to the literal. + // But a `from` that *looks like* an unresolved attribute reference + // (e.g. `claim.manager` when the claim is absent) must NOT be sent to + // the channel verbatim — that would dispatch an approval to a bogus + // `login_hint`. This is an auth boundary: fail closed instead. The + // attribute vocabulary lives here in the runtime, so the invoker + // receives the resolved identity rather than re-deriving it. + let resolved_from = match bag.get_string(&step.from) { + Some(v) => v.to_string(), + None if looks_like_attribute_ref(&step.from) => { + return fail(format!( + "elicitation `from` attribute `{}` did not resolve to an identity", + step.from + )); + }, + None => step.from.clone(), + }; + let id = match bag.get_string(bk::ID) { + Some(existing) => existing.to_string(), + None => match elicitations.dispatch(step, &resolved_from).await { + Ok(d) => { + bag.set(bk::ID, d.id.clone()); + bag.set(bk::STATUS, "pending".to_string()); + // Optional audit label — not a routing key. + if let Some(channel) = &step.channel { + bag.set(bk::CHANNEL, channel.clone()); + } + if let Some(approver) = &d.approver { + bag.set(bk::APPROVER, approver.clone()); + } + if let Some(intent) = &d.intent_id { + bag.set(bk::INTENT_ID, intent.clone()); + } + if let Some(exp) = &d.expires_at { + bag.set(bk::EXPIRES_AT, exp.clone()); + } + d.id + }, + Err(e) => return fail(format!("elicitation dispatch failed: {e}")), + }, + }; + + // Status check — non-blocking read of the channel's current state. + let status = match elicitations.check(step, &id).await { + Ok(s) => s, + Err(e) => return fail(format!("elicitation check failed: {e}")), + }; + + match status { + ElicitationStatus::Pending => { + // Suspend, don't deny: hand the host a pending bundle so it + // emits `-32120` (retry) instead of forwarding. Built from the + // bag, which dispatch populated — works the same on first + // arrival and on a later still-pending retry. + bag.set(bk::STATUS, "pending".to_string()); + let approver = bag.get_string(bk::APPROVER).map(str::to_string); + let intent_id = bag.get_string(bk::INTENT_ID).map(str::to_string); + let expires_at = bag.get_string(bk::EXPIRES_AT).map(str::to_string); + EffectOutcome::Pending(crate::step::PendingElicitation { + id, + plugin_name: step.plugin_name.clone(), + approver, + intent_id, + channel: step.channel.clone(), + expires_at, + source: step.source.clone(), + }) + }, + ElicitationStatus::Expired => { + bag.set(bk::STATUS, "expired".to_string()); + fail("elicitation expired before a response".to_string()) + }, + ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Denied, + } => { + bag.set(bk::STATUS, "resolved".to_string()); + bag.set(bk::OUTCOME, "denied".to_string()); + // A genuine "no" halts unconditionally — not subject to + // `on_error`. + EffectOutcome::Halt(Decision::Deny { + reason: Some("elicitation denied by approver".to_string()), + rule_source: step.source.clone(), + }) + }, + ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Approved, + } => { + // Genuineness (plugin): signature / intent binding / + // responder identity. + let validation = match elicitations.validate(step, &id).await { + Ok(v) => v, + Err(e) => return fail(format!("elicitation validation failed: {e}")), + }; + if !validation.valid { + let why = validation + .reason + .unwrap_or_else(|| "response failed validation".to_string()); + return fail(format!("elicitation invalid: {why}")); + } + + // Sufficiency (runtime): evaluate `scope` against the live + // args. Kept in APL because the channel (Keycloak CIBA) can't + // bind args — no RFC 9396 RAR. + if let Some(scope_src) = &step.scope { + match crate::parser::parse_predicate(scope_src) { + Ok(expr) => { + if !eval_expression(&expr, bag) { + return fail(format!("elicitation scope not satisfied: `{scope_src}`")); + } + }, + Err(e) => { + return fail(format!( + "elicitation scope `{scope_src}` failed to parse: {e}" + )); + }, + } + } + + // Approved + genuine + sufficient. Record resolved facts for + // downstream rules / audit, then continue. + bag.set(bk::STATUS, "resolved".to_string()); + bag.set(bk::OUTCOME, "approved".to_string()); + if let Some(approver) = validation.approver { + bag.set(bk::APPROVER, approver); + } + if let Some(intent) = validation.intent_id { + bag.set(bk::INTENT_ID, intent); + } + EffectOutcome::Continue + }, + } +} + /// Run a list of effects concurrently. Each branch gets its own /// cloned bag and payload — mutations inside a branch don't /// propagate back to the shared outer state. Taints from every @@ -639,6 +901,7 @@ fn dispatch_parallel<'a>( pdp: &'a Arc, plugins: &'a Arc, delegations: &'a Arc, + elicitations: &'a Arc, phase: crate::step::DispatchPhase, taints: &'a mut Vec, payload: &'a crate::route::RoutePayload, @@ -668,6 +931,7 @@ fn dispatch_parallel<'a>( let pdp = Arc::clone(pdp); let plugins = Arc::clone(plugins); let delegations = Arc::clone(delegations); + let elicitations = Arc::clone(elicitations); branches.push(Box::pin(async move { let mut branch_taints: Vec = Vec::new(); let mut branch_args_modified = false; @@ -679,6 +943,7 @@ fn dispatch_parallel<'a>( &pdp, &plugins, &delegations, + &elicitations, phase, &mut branch_taints, &mut branch_args_modified, @@ -1262,6 +1527,12 @@ mod tests { fn noop_delegations() -> Arc { Arc::new(NoopDelegationInvoker) } + fn noop_elicitations() -> Arc { + Arc::new(crate::step::NoopElicitationInvoker) + } + fn auto_elicitations() -> Arc { + Arc::new(crate::step::AutoApprovingElicitor) + } fn deny(reason: &str) -> Effect { Effect::Deny { @@ -1270,6 +1541,24 @@ mod tests { } } + /// Build an `Effect::Elicit` with the given scope / on_error for the + /// elicitation-arm tests. Channel/kind are fixed — the arm doesn't + /// branch on them (that's the plugin's job, which the fakes stub). + fn elicit_effect(scope: Option<&str>, on_error: Option<&str>) -> Effect { + Effect::Elicit(crate::step::ElicitStep { + kind: crate::step::ElicitKind::Approval, + plugin_name: "manager-approver".into(), + channel: Some("ciba".into()), + from: "user.manager".into(), + purpose: Some("approve payroll adjustment".into()), + scope: scope.map(|s| s.to_string()), + timeout: None, + config_override: None, + on_error: on_error.map(|s| s.to_string()), + source: "route.test.policy[0]".into(), + }) + } + fn cond(c: Condition) -> Expression { Expression::Condition(c) } @@ -1450,6 +1739,41 @@ mod tests { )); } + #[test] + fn numeric_string_args_coerce_for_order_comparison() { + // LLMs routinely send numeric tool-args as strings, e.g. + // `args.amount = "25000"`. An order comparison must still fire so a + // gate like `when: args.amount > 10000` (and the elicitation + // `scope: args.amount <= 25000`) work. Regression for the demo bug + // where a string amount slipped past the approval threshold. + let mut bag = AttributeBag::new(); + bag.set("args.amount", "25000"); + let cmp = |op, v: i64| Condition::Comparison { + key: "args.amount".into(), + op, + value: v.into(), + }; + assert!( + eval_condition(&cmp(CompareOp::Gt, 10000), &bag), + "\"25000\" > 10000" + ); + assert!( + eval_condition(&cmp(CompareOp::LtEq, 25000), &bag), + "\"25000\" <= 25000" + ); + assert!( + !eval_condition(&cmp(CompareOp::Gt, 25000), &bag), + "\"25000\" > 25000 is false" + ); + + // A genuinely non-numeric string still doesn't order-compare. + bag.set("args.amount", "lots"); + assert!( + !eval_condition(&cmp(CompareOp::Gt, 10000), &bag), + "\"lots\" > 10000 is false" + ); + } + #[test] fn string_equality_no_ordering() { let mut bag = AttributeBag::new(); @@ -2400,6 +2724,75 @@ mod tests { } } + /// Elicitation invoker that dispatches fine but resolves to a human + /// *denial* — exercises the "genuine no halts unconditionally" path. + struct DenyingElicitor; + #[async_trait] + impl crate::step::ElicitationInvoker for DenyingElicitor { + async fn dispatch( + &self, + _step: &crate::step::ElicitStep, + resolved_from: &str, + ) -> Result { + Ok(crate::step::ElicitationDispatch { + id: "deny-1".into(), + approver: Some(resolved_from.to_string()), + intent_id: None, + expires_at: None, + }) + } + async fn check( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + Ok(crate::step::ElicitationStatus::Resolved { + outcome: crate::step::ElicitationOutcome::Denied, + }) + } + async fn validate( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + // Never reached on a denial — the arm halts before validate. + unreachable!("validate must not run on a denied elicitation") + } + } + + /// Elicitation invoker that dispatches fine but never resolves — + /// every `check` returns Pending. Exercises the suspend path. + struct StillPendingElicitor; + #[async_trait] + impl crate::step::ElicitationInvoker for StillPendingElicitor { + async fn dispatch( + &self, + _step: &crate::step::ElicitStep, + resolved_from: &str, + ) -> Result { + Ok(crate::step::ElicitationDispatch { + id: "pending-1".into(), + approver: Some(resolved_from.to_string()), + intent_id: Some("intent-xyz".into()), + expires_at: Some("2026-12-31T00:00:00Z".into()), + }) + } + async fn check( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + Ok(crate::step::ElicitationStatus::Pending) + } + async fn validate( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + unreachable!("validate must not run while pending") + } + } + /// Plugin invoker keyed by name → outcome. struct FakePlugin { decisions: std::collections::HashMap, @@ -2464,6 +2857,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2485,6 +2879,7 @@ mod tests { &pdp, &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null) ) @@ -2510,6 +2905,7 @@ mod tests { &pdp, &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2553,6 +2949,7 @@ mod tests { &pdp, &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2599,6 +2996,7 @@ mod tests { &pdp, &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2620,6 +3018,7 @@ mod tests { &(Arc::new(ErroringPdp) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2661,6 +3060,7 @@ mod tests { }) as Arc), &plugins, &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null) ) @@ -2685,6 +3085,7 @@ mod tests { }) as Arc), &plugins, &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2713,6 +3114,7 @@ mod tests { }) as Arc), &plugins, &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2756,6 +3158,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -2807,6 +3210,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -2851,6 +3255,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -2884,6 +3289,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -2931,6 +3337,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -2976,6 +3383,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -3013,6 +3421,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -3057,6 +3466,7 @@ mod tests { }) as Arc), &null_plugins(), &noop_delegations(), + &noop_elicitations(), crate::step::DispatchPhase::Pre, &mut payload, ) @@ -3069,4 +3479,281 @@ mod tests { other => panic!("expected Deny, got {:?}", other), } } + + // ----- Effect::Elicit (human-in-the-loop) ----- + + /// Run a single `Effect::Elicit` against the given invoker + bag. + /// Seeds the `user.manager` attribute (the `from` these tests use) so + /// it resolves — an unresolved attribute `from` now fails closed, which + /// its own dedicated test covers. + async fn run_elicit( + effect: Effect, + elicitations: &Arc, + bag: &mut AttributeBag, + ) -> StepsEvaluation { + if bag.get_string("user.manager").is_none() { + bag.set("user.manager", "manager@corp.com"); + } + evaluate_effects( + &[effect], + bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + elicitations, + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await + } + + #[tokio::test] + async fn elicit_auto_approve_allows_and_records_facts() { + let mut bag = AttributeBag::new(); + let eval = run_elicit(elicit_effect(None, None), &auto_elicitations(), &mut bag).await; + assert_eq!(eval.decision, Decision::Allow); + // Resolved facts land in the bag for downstream rules / audit. + assert_eq!(bag.get_string("elicitation.status"), Some("resolved")); + assert_eq!(bag.get_string("elicitation.outcome"), Some("approved")); + assert_eq!( + bag.get_string("elicitation.approver"), + Some("manager@corp.com") + ); + assert_eq!(bag.get_string("elicitation.intent_id"), Some("auto-intent")); + } + + #[tokio::test] + async fn elicit_scope_satisfied_allows() { + // Sufficiency check passes: amount within the approved bound. + let mut bag = AttributeBag::new(); + bag.set("args.amount", 100_i64); + let eval = run_elicit( + elicit_effect(Some("args.amount <= 25000"), None), + &auto_elicitations(), + &mut bag, + ) + .await; + assert_eq!(eval.decision, Decision::Allow); + } + + #[tokio::test] + async fn elicit_scope_unsatisfied_denies_even_when_approved() { + // The human approved, but the live args exceed scope — the runtime + // sufficiency check fails closed regardless of the genuine approval. + let mut bag = AttributeBag::new(); + bag.set("args.amount", 50_000_i64); + let eval = run_elicit( + elicit_effect(Some("args.amount <= 25000"), None), + &auto_elicitations(), + &mut bag, + ) + .await; + match eval.decision { + Decision::Deny { reason, .. } => { + assert!( + reason + .as_deref() + .unwrap_or("") + .contains("scope not satisfied"), + "got: {:?}", + reason + ); + }, + d => panic!("expected scope Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn elicit_noop_dispatch_fails_closed() { + // No channel wired → dispatch errors → fail closed (default deny). + let mut bag = AttributeBag::new(); + let eval = run_elicit(elicit_effect(None, None), &noop_elicitations(), &mut bag).await; + match eval.decision { + Decision::Deny { + reason, + rule_source, + } => { + assert!(reason.as_deref().unwrap_or("").contains("dispatch failed")); + assert_eq!(rule_source, "route.test.policy[0]"); + }, + d => panic!("expected fail-closed Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn elicit_on_error_continue_allows_on_dispatch_failure() { + // Same noop failure, but on_error=continue lets the pipeline pass. + let mut bag = AttributeBag::new(); + let eval = run_elicit( + elicit_effect(None, Some("continue")), + &noop_elicitations(), + &mut bag, + ) + .await; + assert_eq!(eval.decision, Decision::Allow); + } + + #[tokio::test] + async fn elicit_denied_halts_regardless_of_on_error() { + // A genuine human "no" halts even with on_error=continue — on_error + // governs failures, not a valid denial. + let denier: Arc = Arc::new(DenyingElicitor); + let mut bag = AttributeBag::new(); + let eval = run_elicit(elicit_effect(None, Some("continue")), &denier, &mut bag).await; + match eval.decision { + Decision::Deny { reason, .. } => { + assert!(reason + .as_deref() + .unwrap_or("") + .contains("denied by approver")); + }, + d => panic!("expected denial Deny, got {:?}", d), + } + assert_eq!(bag.get_string("elicitation.outcome"), Some("denied")); + } + + #[tokio::test] + async fn elicit_pending_suspends_not_denies() { + // Unresolved elicitation → the phase suspends: decision stays Allow + // and a pending bundle is carried out (the host emits -32120). This + // is the key difference from the fail-closed placeholder. + let pending: Arc = Arc::new(StillPendingElicitor); + let mut bag = AttributeBag::new(); + let eval = run_elicit(elicit_effect(None, None), &pending, &mut bag).await; + + assert_eq!(eval.decision, Decision::Allow, "pending is not a deny"); + let bundle = eval.pending.expect("pending bundle carried out"); + assert_eq!(bundle.id, "pending-1"); + assert_eq!(bundle.plugin_name, "manager-approver"); + assert_eq!(bundle.approver.as_deref(), Some("manager@corp.com")); + assert_eq!(bundle.intent_id.as_deref(), Some("intent-xyz")); + assert_eq!(bundle.channel.as_deref(), Some("ciba")); + assert_eq!(bundle.expires_at.as_deref(), Some("2026-12-31T00:00:00Z")); + assert_eq!(bundle.source, "route.test.policy[0]"); + // Bag reflects the in-flight state for audit. + assert_eq!(bag.get_string("elicitation.status"), Some("pending")); + } + + #[tokio::test] + async fn elicit_pending_even_with_on_error_continue() { + // on_error governs *failures*; a genuine pending is not a failure, + // so on_error=continue must NOT collapse it into a plain allow — + // the bundle still surfaces so the host suspends. + let pending: Arc = Arc::new(StillPendingElicitor); + let mut bag = AttributeBag::new(); + let eval = run_elicit(elicit_effect(None, Some("continue")), &pending, &mut bag).await; + assert_eq!(eval.decision, Decision::Allow); + assert!( + eval.pending.is_some(), + "pending must survive on_error=continue" + ); + } + + #[tokio::test] + async fn elicit_pending_short_circuits_later_effects() { + // A pending elicitation suspends the phase: effects after it in the + // list do not run this pass. A trailing `deny` proves it — if the + // phase kept walking, the decision would be Deny, not Allow+pending. + let pending: Arc = Arc::new(StillPendingElicitor); + let mut bag = AttributeBag::new(); + bag.set("user.manager", "manager@corp.com"); + let effects = vec![elicit_effect(None, None), deny("should not run")]; + let eval = evaluate_effects( + &effects, + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + &pending, + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await; + assert_eq!(eval.decision, Decision::Allow, "trailing deny must not run"); + assert!(eval.pending.is_some()); + } + + #[tokio::test] + async fn elicit_unresolved_from_attribute_fails_closed() { + // `from` is an attribute reference (`user.manager`) that isn't in + // the bag. It must fail closed BEFORE dispatch — never send the + // literal `"user.manager"` to the channel as a bogus login_hint. + let mut bag = AttributeBag::new(); // note: no user.manager seeded + let eval = evaluate_effects( + &[elicit_effect(None, None)], + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + &auto_elicitations(), + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await; + match eval.decision { + Decision::Deny { reason, .. } => { + let r = reason.as_deref().unwrap_or(""); + assert!(r.contains("did not resolve"), "got: {r}"); + assert!(r.contains("user.manager"), "got: {r}"); + }, + d => panic!( + "expected fail-closed Deny on unresolved `from`, got {:?}", + d + ), + } + // Nothing was dispatched, so no elicitation id was recorded. + assert!(bag.get_string("elicitation.id").is_none()); + } + + #[tokio::test] + async fn elicit_literal_from_passes_through_unresolved() { + // A `from` that is a literal identity (an email — not an attribute + // reference) is used verbatim even when it isn't a bag key. + let mut bag = AttributeBag::new(); + let effect = Effect::Elicit(crate::step::ElicitStep { + from: "alice@corp.com".into(), + ..match elicit_effect(None, None) { + Effect::Elicit(e) => e, + _ => unreachable!(), + } + }); + let eval = evaluate_effects( + &[effect], + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + &auto_elicitations(), + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await; + assert_eq!(eval.decision, Decision::Allow); + // The literal identity flowed through as the resolved approver. + assert_eq!( + bag.get_string("elicitation.approver"), + Some("alice@corp.com") + ); + } + + #[test] + fn looks_like_attribute_ref_classification() { + // Attribute references: dotted identifier paths. + assert!(looks_like_attribute_ref("user.manager")); + assert!(looks_like_attribute_ref("claim.manager")); + assert!(looks_like_attribute_ref("user.sub")); + // Literals: emails, display names, bare tokens. + assert!(!looks_like_attribute_ref("alice@corp.com")); + assert!(!looks_like_attribute_ref("Alice Smith")); + assert!(!looks_like_attribute_ref("alice")); + assert!(!looks_like_attribute_ref("")); + } } diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs index bda6e690..d084d38c 100644 --- a/crates/apl-core/src/lib.rs +++ b/crates/apl-core/src/lib.rs @@ -40,7 +40,10 @@ pub use rules::{ PhaseSet, Rule, }; pub use step::{ - delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome, - DispatchPhase, NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, - PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, + delegation_bag_keys, elicitation_bag_keys, AutoApprovingElicitor, DelegateStep, + DelegationError, DelegationInvoker, DelegationOutcome, DispatchPhase, ElicitKind, ElicitStep, + ElicitationDispatch, ElicitationError, ElicitationInvoker, ElicitationOutcome, + ElicitationStatus, ElicitationValidation, NoopDelegationInvoker, NoopElicitationInvoker, + PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, PdpResolver, PendingElicitation, + PluginError, PluginInvocation, PluginInvoker, PluginOutcome, }; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 9901f21c..5006c05c 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -30,7 +30,7 @@ use thiserror::Error; use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck}; use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule}; -use crate::step::{DelegateStep, PdpCall, PdpDialect, Step}; +use crate::step::{DelegateStep, ElicitKind, ElicitStep, PdpCall, PdpDialect, Step}; // ===================================================================== // Errors @@ -922,6 +922,34 @@ fn parse_step_string(line: &str, source: &str) -> Result { })); } + // Elicitation sugar verbs — each desugars to `Step::Elicit` with a + // fixed `ElicitKind`. All-kwarg form (`from:`, `channel:`, …), same + // `key: value` shape as `delegate(...)`. Verbs are matched with the + // trailing `(` so `require_approval` / `require_attestation` / + // `require_review` / `require_step_up` don't collide on the + // `require_` prefix. + for (verb, kind) in ELICIT_VERBS { + if trimmed.starts_with(&format!("{verb}(")) { + let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: format!("malformed `{verb}(...)`"), + })?; + let parsed = parse_elicit_call_args(verb, &inside, source)?; + return Ok(Step::Elicit(ElicitStep { + kind: *kind, + plugin_name: parsed.plugin_name, + channel: parsed.channel, + from: parsed.from, + purpose: parsed.purpose, + scope: parsed.scope, + timeout: parsed.timeout, + config_override: parsed.config_override, + on_error: parsed.on_error, + source: source.to_string(), + })); + } + } + // Otherwise fall through to the rule parser — predicate-and-action. let rule = parse_rule(trimmed, source)?; Ok(Step::Rule(rule)) @@ -1049,6 +1077,157 @@ fn parse_delegate_call_args(inside: &str, source: &str) -> Result, + purpose: Option, + scope: Option, + timeout: Option, + on_error: Option, + config_override: Option, +} + +/// Parse the inside-parens of an elicitation verb, +/// `verb(plugin_name, from: ..., scope: ..., purpose: ..., timeout: ...)`. +/// +/// Shape mirrors `delegate(...)`: the **first positional argument is the +/// `ElicitationHandler` plugin name** (the routing key, resolved +/// `name → entry`). `from` is a required kwarg (the approver, CIBA +/// `login_hint`). `channel` is an OPTIONAL audit label — not a routing +/// key. Recognized keys map to `ElicitStep` fields; `prompt` is an alias +/// for `purpose` (the elicitation-hook doc uses `prompt` for +/// `confirm`/`require_attestation`, `purpose` for `require_approval` — +/// both are the human-readable message). Everything else lands in +/// `config_override` (e.g. `details_link`) for the plugin. +fn parse_elicit_call_args( + verb: &str, + inside: &str, + source: &str, +) -> Result { + let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule { + rule: format!("{verb}({inside})"), + msg: format!("{source}: {msg}"), + })?; + let mut parts_iter = parts.into_iter(); + + // First positional argument: the plugin name (same as delegate()). + let plugin_name = parts_iter + .next() + .map(|s| strip_wrapping_quotes(s.trim()).to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ParseError::Rule { + rule: format!("{verb}({inside})"), + msg: format!( + "{source}: `{verb}(...)` requires an ElicitationHandler plugin name \ + as the first positional argument" + ), + })?; + + let mut from: Option = None; + let mut channel: Option = None; + let mut purpose: Option = None; + let mut scope: Option = None; + let mut timeout: Option = None; + let mut on_error: Option = None; + let mut config_map = serde_yaml::Mapping::new(); + + // Coerce a parsed value to a string, erroring if it isn't one. + let as_string = |value: serde_yaml::Value, key: &str| -> Result { + match value { + serde_yaml::Value::String(s) => Ok(s), + other => Err(ParseError::Rule { + rule: format!("{verb}(...)"), + msg: format!("{source}: `{key}` must be a string, got {other:?}"), + }), + } + }; + + for raw_kwarg in parts_iter { + let kwarg = raw_kwarg.trim(); + if kwarg.is_empty() { + continue; + } + let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: `{verb}(...)` argument `{kwarg}` must be `key: value` \ + (the plugin name is the first positional argument)" + ), + })?; + let key = key.trim(); + let value_str = value_str.trim(); + if key.is_empty() { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `{verb}(...)` argument has empty key"), + }); + } + let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `{key}`: {msg}"), + })?; + match key { + "from" => from = Some(as_string(value, "from")?), + "channel" => channel = Some(as_string(value, "channel")?), + "scope" => scope = Some(as_string(value, "scope")?), + "purpose" | "prompt" => purpose = Some(as_string(value, key)?), + "timeout" => timeout = Some(as_string(value, "timeout")?), + "on_error" => on_error = Some(as_string(value, "on_error")?), + // Reject `plugin:` as a kwarg — it's the positional arg. + "plugin" => { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: the plugin name is the first positional argument \ + of `{verb}(...)`; don't pass it as a kwarg too" + ), + }); + }, + _ => { + config_map.insert(serde_yaml::Value::String(key.to_string()), value); + }, + } + } + + let from = from.ok_or_else(|| ParseError::Rule { + rule: format!("{verb}({inside})"), + msg: format!("{source}: `{verb}(...)` requires `from` (the approver)"), + })?; + + let config_override = if config_map.is_empty() { + None + } else { + Some(serde_yaml::Value::Mapping(config_map)) + }; + + Ok(ParsedElicitCall { + plugin_name, + from, + channel, + purpose, + scope, + timeout, + on_error, + config_override, + }) +} + /// Split a `key: value, key: value` string on TOP-LEVEL commas only — /// commas inside `[...]` or quoted strings are preserved as part of /// the surrounding value. Returns the comma-separated pieces (trimmed @@ -1658,6 +1837,7 @@ pub(crate) fn step_to_top_level_effect(step: Step) -> Result }, Step::Plugin { name } => Ok(Effect::Plugin { name }), Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Elicit(e) => Ok(Effect::Elicit(e)), Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), } } @@ -1666,6 +1846,7 @@ fn step_to_effect(step: Step, source: &str) -> Result { match step { Step::Plugin { name } => Ok(Effect::Plugin { name }), Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Elicit(e) => Ok(Effect::Elicit(e)), Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), Step::Rule(rule) => { // Nested when/do inside a do: list isn't supported in E1 @@ -3836,6 +4017,8 @@ routes: std::sync::Arc::new(NullPluginInvoker); let delegations: std::sync::Arc = std::sync::Arc::new(crate::NoopDelegationInvoker); + let elicitations: std::sync::Arc = + std::sync::Arc::new(crate::NoopElicitationInvoker); // Alice: authenticated, hr role, depth=1 → allow. let mut bag = AttributeBag::new(); @@ -3849,6 +4032,7 @@ routes: &pdp, &plugins, &delegations, + &elicitations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null) ) @@ -3865,6 +4049,7 @@ routes: &pdp, &plugins, &delegations, + &elicitations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -3891,6 +4076,7 @@ routes: &pdp, &plugins, &delegations, + &elicitations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null), ) @@ -4626,6 +4812,169 @@ routes: assert_eq!(post_ds.source, "get_compensation.post_invocation[0]"); } + // ----- elicitation sugar verbs (require_approval, confirm, …) ----- + + fn parse_elicit_str(s: &str) -> crate::step::ElicitStep { + let value = serde_yaml::Value::String(s.to_string()); + let step = parse_step(&value, "test.policy[0]").expect("parse"); + match step { + crate::step::Step::Elicit(e) => e, + other => panic!("expected Elicit, got {other:?}"), + } + } + + #[test] + fn parse_require_approval_full_kwargs() { + let e = parse_elicit_str( + "require_approval(manager-approver, from: user.manager, channel: \"ciba\", \ + scope: \"args.amount <= 25000\", \ + purpose: \"Approve salary change\", timeout: 24h)", + ); + assert_eq!(e.kind, ElicitKind::Approval); + assert_eq!(e.plugin_name, "manager-approver"); + assert_eq!(e.from, "user.manager"); + assert_eq!(e.channel.as_deref(), Some("ciba")); + assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000")); + assert_eq!(e.purpose.as_deref(), Some("Approve salary change")); + assert_eq!(e.timeout.as_deref(), Some("24h")); + assert!(e.on_error.is_none()); + assert!(e.config_override.is_none()); + assert_eq!(e.source, "test.policy[0]"); + } + + #[test] + fn parse_channel_is_optional() { + // No `channel:` — it's an audit label, not required for routing. + let e = parse_elicit_str("require_approval(manager-approver, from: user.manager)"); + assert_eq!(e.plugin_name, "manager-approver"); + assert!(e.channel.is_none()); + } + + #[test] + fn parse_each_verb_maps_to_its_kind() { + for (verb, want) in [ + ("require_approval", ElicitKind::Approval), + ("confirm", ElicitKind::Confirm), + ("require_step_up", ElicitKind::StepUp), + ("require_attestation", ElicitKind::Attestation), + ("request_info", ElicitKind::Info), + ("require_review", ElicitKind::Review), + ] { + let e = parse_elicit_str(&format!("{verb}(inband-asker, from: user.sub)")); + assert_eq!(e.kind, want, "verb `{verb}`"); + assert_eq!(e.plugin_name, "inband-asker"); + assert_eq!(e.from, "user.sub"); + } + } + + #[test] + fn parse_confirm_prompt_aliases_purpose() { + // The elicitation-hook doc uses `prompt` for confirm; it maps to + // the same `purpose` field as `require_approval`. + let e = + parse_elicit_str("confirm(inband-asker, from: user.sub, prompt: \"Drop the table?\")"); + assert_eq!(e.kind, ElicitKind::Confirm); + assert_eq!(e.purpose.as_deref(), Some("Drop the table?")); + } + + #[test] + fn parse_unknown_kwarg_goes_to_config_override() { + let e = parse_elicit_str( + "require_approval(slack-approver, from: user.manager, \ + details_link: https://approvals.example.com/req)", + ); + let cfg = e.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert_eq!( + cfg.get(serde_yaml::Value::String("details_link".into())) + .and_then(|v| v.as_str()), + Some("https://approvals.example.com/req"), + ); + // Recognized keys must NOT leak into config_override. + assert!(cfg.get(serde_yaml::Value::String("from".into())).is_none()); + } + + #[test] + fn parse_on_error_pulled_out() { + let e = parse_elicit_str( + "require_approval(manager-approver, from: user.manager, on_error: continue)", + ); + assert_eq!(e.on_error.as_deref(), Some("continue")); + assert!(e.config_override.is_none()); + } + + #[test] + fn parse_missing_plugin_name_errors() { + let value = serde_yaml::Value::String("require_approval(from: user.manager)".into()); + let err = parse_step(&value, "test.policy[0]").expect_err("missing plugin name"); + // `from: user.manager` parses as the positional first arg, so the + // missing piece surfaces as the required `from` kwarg. + assert!(format!("{err}").contains("requires `from`"), "got: {err}"); + } + + #[test] + fn parse_empty_args_errors() { + let value = serde_yaml::Value::String("require_approval()".into()); + let err = parse_step(&value, "test.policy[0]").expect_err("empty args"); + assert!(format!("{err}").contains("plugin name"), "got: {err}"); + } + + #[test] + fn parse_plugin_kwarg_rejected() { + // Passing `plugin:` as a kwarg is ambiguous with the positional. + let value = serde_yaml::Value::String( + "require_approval(manager-approver, plugin: other, from: user.manager)".into(), + ); + let err = parse_step(&value, "test.policy[0]").expect_err("plugin kwarg"); + assert!(format!("{err}").contains("first positional"), "got: {err}"); + } + + #[test] + fn parse_missing_from_errors() { + let value = serde_yaml::Value::String("require_approval(manager-approver)".into()); + let err = parse_step(&value, "test.policy[0]").expect_err("missing from"); + assert!(format!("{err}").contains("requires `from`")); + } + + #[test] + fn parse_require_prefixed_verbs_do_not_collide() { + // `require_attestation` must not be swallowed by a `require_a*` + // partial match — each verb is matched with its trailing `(`. + let e = parse_elicit_str("require_attestation(inband-asker, from: user.sub)"); + assert_eq!(e.kind, ElicitKind::Attestation); + } + + #[test] + fn compile_route_with_require_approval_in_policy() { + // End-to-end: the sugar verb survives compile_config and lands as + // an Effect::Elicit at the right phase/source. The `when`-gated + // form mirrors the manager-approval design doc. + let yaml = r#" +routes: + payroll_adjust: + pre_invocation: + - "require(authenticated)" + - when: "args.amount > 10000" + do: + - "require_approval(manager-approver, from: user.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve salary change\", timeout: 24h)" +"#; + let cfg = compile_config(yaml).expect("compile"); + let route = cfg.routes.get("payroll_adjust").expect("route present"); + assert_eq!(route.policy.len(), 2); + + // policy[1] is the `when` wrapper; its body[0] is the elicitation. + let crate::rules::Effect::When { body, .. } = &route.policy[1] else { + panic!("expected When at policy[1], got {:?}", route.policy[1]); + }; + let crate::rules::Effect::Elicit(e) = &body[0] else { + panic!("expected Elicit in when-body, got {:?}", body[0]); + }; + assert_eq!(e.kind, ElicitKind::Approval); + assert_eq!(e.plugin_name, "manager-approver"); + assert_eq!(e.from, "user.manager"); + assert_eq!(e.channel.as_deref(), Some("ciba")); + assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000")); + } + // ----- validate(name) compile-time rejection (DSL spec §4.2) ----- #[test] diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs index 13f02aad..3ede63a0 100644 --- a/crates/apl-core/src/route.rs +++ b/crates/apl-core/src/route.rs @@ -30,7 +30,9 @@ use crate::attributes::AttributeBag; use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome}; use crate::pipeline::TaintEvent; use crate::rules::CompiledRoute; -use crate::step::{DelegationInvoker, DispatchPhase, PdpResolver, PluginInvoker}; +use crate::step::{ + DelegationInvoker, DispatchPhase, ElicitationInvoker, PdpResolver, PluginInvoker, +}; /// Mutable payload for a route invocation. `args` is the request arguments /// object; `result` is the response object (`None` on the inbound path, @@ -64,6 +66,12 @@ pub struct RouteDecision { pub args_modified: bool, /// True if any result field was rewritten or omitted. pub result_modified: bool, + /// Set when a phase suspended on an unresolved elicitation. `Some` + /// means the host must emit JSON-RPC `-32120` (retry) and **not** + /// forward — `decision` is `Allow` in that case. The host forwards + /// only when `decision` is `Allow` AND `pending.is_none()`. See + /// [`crate::step::PendingElicitation`]. + pub pending: Option, } /// Run the **pre-invocation** phases: `args` then `policy`. Used by @@ -83,6 +91,7 @@ pub async fn evaluate_pre( pdp: &Arc, plugins: &Arc, delegations: &Arc, + elicitations: &Arc, ) -> RouteDecision { let mut taints: Vec = Vec::new(); let mut args_modified = false; @@ -126,6 +135,7 @@ pub async fn evaluate_pre( taints, args_modified, result_modified: false, + pending: None, }; }, } @@ -138,6 +148,7 @@ pub async fn evaluate_pre( pdp, plugins, delegations, + elicitations, DispatchPhase::Pre, payload, ) @@ -151,6 +162,7 @@ pub async fn evaluate_pre( taints, args_modified, result_modified: false, + pending: policy_eval.pending, } } @@ -168,6 +180,7 @@ pub async fn evaluate_post( pdp: &Arc, plugins: &Arc, delegations: &Arc, + elicitations: &Arc, ) -> RouteDecision { let mut taints: Vec = Vec::new(); let mut result_modified = false; @@ -212,6 +225,7 @@ pub async fn evaluate_post( taints, args_modified: false, result_modified, + pending: None, }; }, } @@ -225,6 +239,7 @@ pub async fn evaluate_post( pdp, plugins, delegations, + elicitations, DispatchPhase::Post, payload, ) @@ -239,6 +254,7 @@ pub async fn evaluate_post( taints, args_modified: false, result_modified, + pending: post_eval.pending, } } @@ -259,12 +275,17 @@ pub async fn evaluate_route( pdp: &Arc, plugins: &Arc, delegations: &Arc, + elicitations: &Arc, ) -> RouteDecision { - let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations).await; - if matches!(pre.decision, Decision::Deny { .. }) { + let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations, elicitations).await; + // Halt before the tool call on a pre-side Deny OR a pending + // elicitation. Pending means the inbound phase suspended awaiting a + // human — the tool must not run, so `post` (which processes the tool's + // response) is skipped and the host emits `-32120` from `pre.pending`. + if matches!(pre.decision, Decision::Deny { .. }) || pre.pending.is_some() { return pre; } - let post = evaluate_post(route, bag, payload, pdp, plugins, delegations).await; + let post = evaluate_post(route, bag, payload, pdp, plugins, delegations, elicitations).await; let mut taints = pre.taints; taints.extend(post.taints); RouteDecision { @@ -272,6 +293,7 @@ pub async fn evaluate_route( taints, args_modified: pre.args_modified, result_modified: post.result_modified, + pending: post.pending, } } @@ -353,8 +375,8 @@ mod tests { use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck}; use crate::rules::{Effect, Expression, Rule}; use crate::step::{ - NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PluginError, - PluginInvocation, PluginOutcome, + NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, + PluginError, PluginInvocation, PluginOutcome, }; use async_trait::async_trait; use serde_json::json; @@ -392,6 +414,39 @@ mod tests { } } + /// Elicitation invoker that always reports Pending — for the + /// route-level suspend test. + struct PendingElicitor; + #[async_trait] + impl ElicitationInvoker for PendingElicitor { + async fn dispatch( + &self, + _step: &crate::step::ElicitStep, + _resolved_from: &str, + ) -> Result { + Ok(crate::step::ElicitationDispatch { + id: "elic-route-1".into(), + approver: None, + intent_id: None, + expires_at: None, + }) + } + async fn check( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + Ok(crate::step::ElicitationStatus::Pending) + } + async fn validate( + &self, + _step: &crate::step::ElicitStep, + _id: &str, + ) -> Result { + unreachable!("validate must not run while pending") + } + } + // `evaluate_route` takes `&Arc` / `&Arc` // so the path through `dispatch_parallel` can `Arc::clone` into each // spawned branch. These helpers wrap the no-op test stubs once per call. @@ -404,6 +459,9 @@ mod tests { fn delegations() -> Arc { Arc::new(NoopDelegationInvoker) } + fn elicitations() -> Arc { + Arc::new(NoopElicitationInvoker) + } fn field_rule(field: &str, stages: Vec) -> FieldRule { FieldRule { @@ -438,6 +496,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -446,6 +505,58 @@ mod tests { assert!(r.taints.is_empty()); } + #[tokio::test] + async fn pending_elicitation_suspends_route_and_skips_post() { + // A pending elicitation in `policy:` must suspend the whole route: + // decision Allow + pending Some, and the `result:` phase (which + // would mutate the response) must NOT run. + let mut route = CompiledRoute::new("payroll"); + route.policy.push(Effect::Elicit(crate::step::ElicitStep { + kind: crate::step::ElicitKind::Approval, + plugin_name: "manager-approver".into(), + channel: Some("ciba".into()), + from: "user.manager".into(), + purpose: None, + scope: None, + timeout: None, + config_override: None, + on_error: None, + source: "payroll.policy[0]".into(), + })); + // A result rule that WOULD mask — proves post didn't run if untouched. + route + .result + .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }])); + + let elicitor: Arc = Arc::new(PendingElicitor); + let mut bag = AttributeBag::new(); + // The step's `from` is an attribute ref — seed it so it resolves + // (an unresolved attribute `from` now fails closed by design). + bag.set("user.manager", "manager@corp.com"); + let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789" })); + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + &elicitor, + ) + .await; + + assert_eq!(r.decision, Decision::Allow, "pending is not a deny"); + let bundle = r.pending.expect("route surfaced the pending bundle"); + assert_eq!(bundle.id, "elic-route-1"); + assert_eq!(bundle.plugin_name, "manager-approver"); + // post never ran → result untouched (no masking applied). + assert!(!r.result_modified); + assert_eq!( + payload.result.as_ref().unwrap()["ssn"], + json!("123-45-6789") + ); + } + #[tokio::test] async fn args_pipeline_mutates_payload() { let mut route = CompiledRoute::new("ping"); @@ -461,6 +572,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -497,6 +609,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -529,6 +642,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -548,6 +662,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -576,6 +691,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -602,6 +718,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -624,6 +741,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -662,6 +780,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -687,6 +806,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -712,6 +832,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -738,6 +859,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -809,6 +931,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -848,6 +971,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -885,6 +1009,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -929,6 +1054,7 @@ mod tests { &pdp_arc(), &plugins(), &delegations(), + &elicitations(), ) .await; assert!(matches!(r.decision, Decision::Deny { .. })); diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs index ea008b97..8e3b0de7 100644 --- a/crates/apl-core/src/rules.rs +++ b/crates/apl-core/src/rules.rs @@ -175,6 +175,13 @@ pub enum Effect { name: String, }, Delegate(crate::step::DelegateStep), + /// Elicitation effect — dispatch a question to a human (approval, + /// confirmation, step-up, …) through a channel plugin, hold pending + /// state across the agent's retries, validate the response, resume. + /// The elicitation analogue of `Delegate`. See + /// `docs/apl-manager-approval-ciba-design.md` and + /// [`crate::step::ElicitStep`]. + Elicit(crate::step::ElicitStep), Taint { label: String, scopes: Vec, @@ -250,7 +257,10 @@ impl Effect { /// branch. pub fn contains_mutation(&self) -> bool { match self { - Effect::FieldOp { .. } | Effect::Delegate(_) => true, + // `Elicit` has an external side effect (posts to a channel, + // registers an intent) — like `Delegate` it must not sit in + // a `Parallel` branch that could be silently discarded. + Effect::FieldOp { .. } | Effect::Delegate(_) | Effect::Elicit(_) => true, Effect::Sequential(effects) | Effect::Parallel(effects) => { effects.iter().any(Effect::contains_mutation) }, diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index d0e74acb..5cfa7094 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -73,6 +73,13 @@ pub(crate) enum Step { label: String, scopes: Vec, }, + + /// `require_approval(...)` / `confirm(...)` / … — dispatch an + /// elicitation to a human and resume once resolved. The elicitation + /// analogue of `Delegate`; resolution is dispatched to an + /// `ElicitationHandler` plugin via apl-cpex. See + /// `docs/apl-manager-approval-ciba-design.md`. + Elicit(ElicitStep), } /// One delegation invocation inside `pre_invocation:` or `post_invocation:`. @@ -137,6 +144,144 @@ pub struct DelegateStep { pub source: String, } +/// The kind of elicitation — selects which validation contract the +/// runtime applies to the human's response. A single AST node +/// ([`Step::Elicit`]) covers every kind; the DSL exposes each via a +/// sugar verb (`require_approval` → `Approval`, `confirm` → `Confirm`, +/// …) that all parse to the same node. See +/// `docs/apl-elicitation-hook-design.md` for the per-kind contracts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitKind { + /// Yes/no decision from a designated approver (manager approval). + /// The approver MAY differ from the request subject; the response is + /// bound to the request's args via `scope`. + Approval, + /// Cheap yes/no from the originating user ("yes, really do this"). + Confirm, + /// Re-auth / second factor by the originating user (fresh token, + /// elevated `acr`). + StepUp, + /// Signed statement from a designated party ("I confirm I reviewed X"). + Attestation, + /// Free-form clarification from the originating user. + Info, + /// Peer review of an action by a colleague. + Review, +} + +impl ElicitKind { + /// The snake_case wire name (matches the serde representation). Used + /// by the apl-cpex bridge to pass `kind` to channel plugins as a + /// string, since cpex-core can't depend on this enum. + pub fn as_str(&self) -> &'static str { + match self { + ElicitKind::Approval => "approval", + ElicitKind::Confirm => "confirm", + ElicitKind::StepUp => "step_up", + ElicitKind::Attestation => "attestation", + ElicitKind::Info => "info", + ElicitKind::Review => "review", + } + } +} + +/// One elicitation invocation inside `policy:` or `post_policy:` — the +/// runtime dispatches a question to a human (approval, confirmation, +/// step-up, …) through a channel plugin, holds a pending state across +/// the agent's retries, validates the response, and resumes. +/// +/// Structurally the elicitation analogue of [`DelegateStep`]: the DSL +/// carries the verb; apl-cpex dispatches resolution to the named +/// `ElicitationHandler` plugin (`plugin_name`, resolved exactly like +/// `delegate(...)`). The key +/// difference from delegation — which completes within one request — is +/// that an elicitation spans the gap between *dispatch* (the first +/// request that hits this step) and *resolution* (a later retry). That +/// gap is owned by the channel (e.g. Keycloak CIBA), never by a plugin +/// call: each of dispatch/check/validate is short and synchronous to the +/// request it runs in. See `docs/apl-manager-approval-ciba-design.md`. +/// +/// # First arrival vs. retry +/// +/// On the first request that reaches this step, the runtime *dispatches* +/// the elicitation and the phase yields a pending entry (the host emits +/// JSON-RPC `-32120`). On a later retry carrying the elicitation id, the +/// runtime *checks* status and, once resolved, *validates* the response +/// against `scope` before the phase may proceed. +/// +/// `config_override` is a free-form map for channel-specific params +/// (e.g. CIBA `details_link`, Slack block-kit options); apl-core treats +/// it as opaque and hands it to the plugin via the same per-call +/// config-override pathway delegation uses. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ElicitStep { + /// Which elicitation contract applies (selects runtime validation). + pub kind: ElicitKind, + + /// Name of the `ElicitationHandler` plugin to invoke — the routing + /// key, resolved `name → entry` exactly like `delegate(...)` resolves + /// its plugin. The first positional argument of the sugar verb (e.g. + /// `require_approval(manager-approver, ...)`). Which backend it speaks + /// (CIBA / Slack / in-band) is the plugin's own opaque config, not + /// something apl-core interprets. + pub plugin_name: String, + + /// Optional channel label for audit/observability only (e.g. + /// `"ciba"`, `"slack"`). NOT a routing key — the framework never + /// dispatches on it. Surfaced into the bag as `elicitation.channel` + /// so the audit record can show how the human was reached. `None` + /// when the author doesn't declare one (a Phase 2 plugin may report + /// its own channel instead). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, + + /// Who is being asked — an attribute reference resolved against the + /// policy bag at dispatch (e.g. `"user.manager"`, `"user.sub"`). For + /// CIBA this becomes `login_hint`; the resolved identity is + /// cross-checked against the responder at `validate()`. + pub from: String, + + /// Canonical, human-readable description of what's being asked, with + /// request-arg substitution. Audited verbatim and shown to the + /// responder (CIBA `binding_message`) — the source of truth for + /// "what was approved," never an LLM summary. `None` for kinds that + /// carry their prompt elsewhere. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, + + /// APL boolean expression the runtime evaluates against the actual + /// request args at `validate()` to confirm the response covers what + /// was requested (e.g. `"args.amount <= 25000"`). This is the + /// args-binding layer — kept in APL because Keycloak does not support + /// RFC 9396 RAR. `None` for kinds without arg binding (e.g. a bare + /// `confirm`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + + /// How long the elicitation stays valid before expiring (e.g. + /// `"24h"`). Surfaces as CIBA `requested_expiry`. `None` defers to + /// the channel plugin's configured default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + + /// Per-call config overrides for channel-specific params, layered on + /// the plugin's default config. Opaque to apl-core. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_override: Option, + + /// `deny | continue` — what to do when dispatch or validation fails + /// (channel error, invalid response). `None` defaults to `"deny"` + /// (fail-closed; matches delegation/PDP step semantics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option, + + /// Human-readable source path (e.g. + /// `"route.payroll_adjust.policy[0]"`) — used in audit and + /// `Decision::Deny.rule_source` when the step denies. + pub source: String, +} + /// A PDP invocation, opaque-args style. Resolvers parse `args` based on /// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen /// schema on `args`. @@ -395,6 +540,283 @@ impl DelegationInvoker for NoopDelegationInvoker { } } +// ===================================================================== +// Elicitation dispatch +// ===================================================================== + +/// Elicitation dispatch — drives a human-in-the-loop step (approval, +/// confirmation, step-up, …) through a channel plugin. apl-cpex +/// implements this against the named `ElicitationHandler` plugin +/// (`step.plugin_name`, resolved `name → entry` like delegation); tests +/// and un-wired hosts pass [`NoopElicitationInvoker`]. +/// +/// Three short, synchronous touchpoints span the human's (possibly +/// hours-long) decision. The wait itself lives in the channel (e.g. +/// Keycloak CIBA), never inside a trait call: +/// +/// * [`dispatch`](ElicitationInvoker::dispatch) — once, on the first +/// request that reaches the step: register the intent, open the +/// backchannel, and return the id the agent echoes on retry. +/// * [`check`](ElicitationInvoker::check) — on every retry: read the +/// current status (pending / resolved / expired) without blocking. +/// * [`validate`](ElicitationInvoker::validate) — once status is +/// resolved: confirm the response is *genuine* (signature, intent +/// binding, responder identity). The *sufficiency* check — +/// [`ElicitStep::scope`] against the live request args — is the +/// runtime's job, not the plugin's, because `scope` is an APL +/// expression the plugin cannot evaluate. +/// +/// Like [`DelegationInvoker`], the invoker holds the request-scoped +/// `Extensions` internally, so the trait methods take only the step / id +/// and never the request context. +#[async_trait] +pub trait ElicitationInvoker: Send + Sync { + /// First arrival. Register the intent and open the channel + /// backchannel for `step`, returning the correlation id plus the + /// pending metadata the evaluator writes into the bag + /// (`elicitation.id` / `.approver` / `.intent_id`). Short and + /// synchronous — the human's decision happens *after* this returns, + /// inside the channel. + /// + /// `resolved_from` is `step.from` already resolved against the request + /// bag by the runtime (e.g. `claim.manager` → the manager's actual + /// identity), or the literal `step.from` when it isn't a bag key. The + /// attribute vocabulary lives in the runtime, so the invoker receives + /// the resolved identity rather than re-resolving it — for CIBA this + /// becomes the `login_hint`. + async fn dispatch( + &self, + step: &ElicitStep, + resolved_from: &str, + ) -> Result; + + /// Retry. Read the current status of a dispatched elicitation by + /// `id` without blocking — `Pending` until the human acts, then + /// `Resolved` (carrying approved/denied) or `Expired`. `step` is + /// passed (the same step that dispatched) so the invoker can resolve + /// which handler plugin owns this elicitation — on a retry only the + /// id is in the bag, but the step is still in scope. + async fn check( + &self, + step: &ElicitStep, + id: &str, + ) -> Result; + + /// Resolution. Verify that the resolved response is *genuine* — the + /// signed token validates, its intent binding matches this `id`, and + /// the responder is the resolved approver. Returns the verdict plus + /// the facts the evaluator records for audit. The runtime applies the + /// `scope`-over-args check separately before honoring an approval. + /// `step` resolves the owning handler plugin (see [`check`]). + /// + /// [`check`]: ElicitationInvoker::check + async fn validate( + &self, + step: &ElicitStep, + id: &str, + ) -> Result; +} + +/// What [`ElicitationInvoker::dispatch`] returns — the correlation id +/// plus the pending metadata the evaluator surfaces into the bag +/// (`elicitation.*`) and the host echoes in the JSON-RPC `-32120` +/// pending entry. +#[derive(Debug, Clone)] +pub struct ElicitationDispatch { + /// Server-side id the agent echoes on retry. Keys the + /// `{requester, args, scope, original_request_id}` record. + pub id: String, + /// Resolved approver identity (the `from` attr resolved at dispatch, + /// e.g. the manager's `sub`). `None` when the channel resolves the + /// responder only at validation time. Surfaced as + /// `elicitation.approver`. + pub approver: Option, + /// Registered intent id (lodging-intent binding) when the channel + /// supports it. Surfaced as `elicitation.intent_id`. + pub intent_id: Option, + /// When the elicitation expires (RFC 3339). `None` defers to the + /// channel plugin's configured default. + pub expires_at: Option, +} + +/// Current state of a dispatched elicitation, read by +/// [`ElicitationInvoker::check`] on each retry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ElicitationStatus { + /// The human has not responded yet — the phase stays pending and the + /// host re-emits `-32120`. + Pending, + /// The human responded. `outcome` carries approved/denied; the + /// runtime still calls `validate` before honoring an `Approved`. + Resolved { outcome: ElicitationOutcome }, + /// The elicitation timed out before a response — the runtime fails + /// closed (subject to the step's `on_error`). + Expired, +} + +/// The human's decision once an elicitation resolves. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElicitationOutcome { + Approved, + Denied, +} + +/// What [`ElicitationInvoker::validate`] returns — the *genuineness* +/// verdict plus the resolved facts the runtime records for audit. The +/// runtime layers the `scope`-over-args check on top before allowing the +/// phase to proceed. +#[derive(Debug, Clone)] +pub struct ElicitationValidation { + /// `true` when the response is genuine: the signed token validates, + /// its intent binding matches this elicitation, and the responder is + /// the resolved approver. + pub valid: bool, + /// Who actually consented — cross-checked against the dispatch-time + /// approver. Recorded as `elicitation.approver`. + pub approver: Option, + /// Intent id carried in the signed response, for audit + /// reconciliation against the registered intent. + pub intent_id: Option, + /// Why validation failed, when `valid` is `false`. `None` on success. + pub reason: Option, +} + +/// The "ask again later" bundle — produced when an elicitation has been +/// dispatched but the human hasn't responded yet. It carries everything +/// the host needs to emit a JSON-RPC `-32120` ("request not complete, +/// retry echoing this id") to the agent instead of forwarding the call. +/// +/// This is the tri-state channel that lets `Decision` stay binary: a +/// suspended phase reports `Decision::Allow` (nothing was *denied*) with a +/// `Some(PendingElicitation)` alongside it. The host rule is one clause — +/// **forward iff `Allow` AND `pending.is_none()`**; otherwise emit +/// `-32120`. The agent re-sends with `elicitation.id`, the runtime takes +/// the "id present → check, don't re-dispatch" path, and once the human +/// resolves, the phase proceeds past the elicitation. +/// +/// Pending **short-circuits** the phase (sequential elicitation): at most +/// one pending per pass. Multiple concurrent pendings are deferred (would +/// turn this into a `Vec` on `StepsEvaluation`). +#[derive(Debug, Clone, PartialEq)] +pub struct PendingElicitation { + /// Server-side id the agent echoes on retry (`elicitation.id`). + pub id: String, + /// Which `ElicitationHandler` plugin owns this elicitation. + pub plugin_name: String, + /// Resolved approver identity, when known at dispatch. + pub approver: Option, + /// Registered intent id (lodging-intent binding), when the channel + /// supports it. + pub intent_id: Option, + /// Optional channel label for the agent-facing `-32120` / audit. + pub channel: Option, + /// When the elicitation expires (RFC 3339), when known. + pub expires_at: Option, + /// Rule source path of the originating `Elicit` step, for audit. + pub source: String, +} + +#[derive(Debug, Error)] +pub enum ElicitationError { + #[error("no elicitation invoker available for plugin `{0}`")] + NotFound(String), + + /// The handler failed to service an operation (dispatch / check / + /// validate) — a channel error, a handler deny, or a malformed + /// response. The message names the operation; the evaluator routes + /// this through the step's `on_error`. + #[error("elicitation handler error: {0}")] + Handler(String), +} + +/// [`ElicitationInvoker`] impl that returns `NotFound` for every call. +/// The default for evaluator callers that run no elicitation steps — +/// they must pass *something* implementing the trait, but the noop never +/// actually gets invoked. Mirrors [`NoopDelegationInvoker`]; tests and +/// hosts that haven't wired a real channel backend pass this. +pub struct NoopElicitationInvoker; + +#[async_trait] +impl ElicitationInvoker for NoopElicitationInvoker { + async fn dispatch( + &self, + step: &ElicitStep, + _resolved_from: &str, + ) -> Result { + Err(ElicitationError::NotFound(step.plugin_name.clone())) + } + + async fn check( + &self, + _step: &ElicitStep, + id: &str, + ) -> Result { + Err(ElicitationError::NotFound(id.to_string())) + } + + async fn validate( + &self, + _step: &ElicitStep, + id: &str, + ) -> Result { + Err(ElicitationError::NotFound(id.to_string())) + } +} + +/// `ElicitationInvoker` that immediately approves every elicitation: +/// `dispatch` returns a synthetic id (echoing the requested `from` as the +/// resolved approver), `check` reports `Resolved { Approved }` on the +/// first pass, and `validate` returns a genuine verdict. This lets a +/// single request flow dispatch → check → validate → allow without a real +/// channel — for evaluator tests and offline demos. +/// +/// NOT for production: it makes no actual approval decision. Hosts wire a +/// real channel invoker (e.g. the apl-cpex `ElicitationHandler` bridge). +#[derive(Default)] +pub struct AutoApprovingElicitor; + +#[async_trait] +impl ElicitationInvoker for AutoApprovingElicitor { + async fn dispatch( + &self, + step: &ElicitStep, + resolved_from: &str, + ) -> Result { + Ok(ElicitationDispatch { + id: format!("auto-{}", step.plugin_name), + // Echo the *resolved* approver, as a real channel would. + approver: Some(resolved_from.to_string()), + intent_id: Some("auto-intent".to_string()), + expires_at: None, + }) + } + + async fn check( + &self, + _step: &ElicitStep, + _id: &str, + ) -> Result { + Ok(ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Approved, + }) + } + + async fn validate( + &self, + _step: &ElicitStep, + _id: &str, + ) -> Result { + Ok(ElicitationValidation { + valid: true, + // Leave approver/intent unset — the dispatch-time values + // already recorded in the bag stand. + approver: None, + intent_id: Some("auto-intent".to_string()), + reason: None, + }) + } +} + // ===================================================================== // Resolver results // ===================================================================== @@ -511,6 +933,37 @@ pub mod delegation_bag_keys { pub const GRANTED: &str = "delegation.granted"; } +/// Bag keys an elicitation step writes so downstream rules in the same +/// phase — and the audit plugin — can read its state. Centralized here +/// (like [`delegation_bag_keys`]) so the evaluator/invoker (writers) and +/// policy authors (readers, via `require(elicitation.*)`) agree on the +/// canonical names. +/// +/// On *dispatch* the runtime writes `id` + `status = "pending"` (plus +/// `approver` / `intent_id` when known). On *resolution* it updates +/// `status` and sets `outcome`. A phase with a pending elicitation does +/// not forward (see `docs/apl-manager-approval-ciba-design.md`). +pub mod elicitation_bag_keys { + /// `String` — the elicitation id the agent echoes on retry. Server-side + /// key into `{requester, args, scope, original_request_id}`. + pub const ID: &str = "elicitation.id"; + /// `String` — `pending | resolved | expired`. + pub const STATUS: &str = "elicitation.status"; + /// `String` — resolved approver identity, cross-checked against `from`. + pub const APPROVER: &str = "elicitation.approver"; + /// `String` — `approved | denied` once resolved. + pub const OUTCOME: &str = "elicitation.outcome"; + /// `String` — registered intent id (lodging-intent binding), echoed in + /// the OP-signed token for `validate()` and audit reconciliation. + pub const INTENT_ID: &str = "elicitation.intent_id"; + /// `String` — optional channel label (`ciba` / `slack` / …) for + /// audit/observability. Not a routing key. + pub const CHANNEL: &str = "elicitation.channel"; + /// `String` — when the elicitation expires (RFC 3339), when the + /// channel reported one at dispatch. + pub const EXPIRES_AT: &str = "elicitation.expires_at"; +} + #[cfg(test)] mod tests { use super::*; @@ -532,6 +985,51 @@ mod tests { ); } + #[tokio::test] + async fn noop_elicitation_invoker_is_not_found_for_every_method() { + // The noop must never silently succeed — every method reports + // NotFound so an un-wired host fails closed rather than treating + // an elicitation step as approved. + let inv = NoopElicitationInvoker; + let step = ElicitStep { + kind: ElicitKind::Approval, + plugin_name: "manager-approver".to_string(), + channel: Some("ciba".to_string()), + from: "user.manager".to_string(), + purpose: None, + scope: None, + timeout: None, + config_override: None, + on_error: None, + source: "route.test.policy[0]".to_string(), + }; + + let d = inv.dispatch(&step, "alice@example.com").await; + assert!(matches!(d, Err(ElicitationError::NotFound(c)) if c == "manager-approver")); + + let c = inv.check(&step, "elic-123").await; + assert!(matches!(c, Err(ElicitationError::NotFound(id)) if id == "elic-123")); + + let v = inv.validate(&step, "elic-123").await; + assert!(matches!(v, Err(ElicitationError::NotFound(id)) if id == "elic-123")); + } + + #[test] + fn elicitation_status_resolved_carries_outcome() { + // Resolved is distinct from its outcome — a denied resolution is + // still "resolved" (the runtime stops retrying) but must not be + // confused with Pending/Expired. + let approved = ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Approved, + }; + let denied = ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Denied, + }; + assert_ne!(approved, denied); + assert_ne!(approved, ElicitationStatus::Pending); + assert_ne!(denied, ElicitationStatus::Expired); + } + #[test] fn cel_dialect_serde_roundtrips_as_snake_case() { // `Cel` is a tagged variant (snake_case) — must round-trip so diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs index 62010191..8ef30593 100644 --- a/crates/apl-core/tests/yaml_end_to_end.rs +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -15,9 +15,10 @@ use std::sync::Arc; use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, FieldOutcome, - NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, - PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, ElicitationInvoker, + FieldOutcome, NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, + PdpError, PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, + RoutePayload, }; use async_trait::async_trait; use serde_json::json; @@ -34,6 +35,9 @@ fn plugins() -> Arc { fn delegations() -> Arc { Arc::new(NoopDelegationInvoker) } +fn elicitations() -> Arc { + Arc::new(NoopElicitationInvoker) +} // ----- Fixtures: a baseline route used by every scenario below. ----- @@ -112,6 +116,7 @@ async fn alice_full_access_sees_unredacted_result_with_masked_id() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -157,6 +162,7 @@ async fn mallory_no_perm_no_role_gets_both_fields_redacted() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); @@ -191,6 +197,7 @@ async fn deep_delegation_denies_at_policy() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -233,6 +240,7 @@ async fn unauthenticated_user_is_denied_before_args_mutate_result() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert!(matches!(r.decision, Decision::Deny { .. })); @@ -262,6 +270,7 @@ async fn args_validator_rejects_wrong_type() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; match r.decision { @@ -297,6 +306,7 @@ async fn inbound_only_evaluation_skips_result_phase() { &pdp(), &plugins(), &delegations(), + &elicitations(), ) .await; assert_eq!(r.decision, Decision::Allow); diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs index 1344518e..8023c268 100644 --- a/crates/apl-cpex/src/dispatch_plan.rs +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -44,6 +44,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; use cpex_core::delegation::HOOK_TOKEN_DELEGATE; +use cpex_core::elicitation::HOOK_ELICIT; use cpex_core::hooks::{lookup_hook_metadata, HookPhase}; use cpex_core::manager::PluginManager; use cpex_core::plugin::OnError; @@ -119,6 +120,12 @@ pub struct RouteDispatchPlan { /// no delegation. Built at plan time to avoid per-request /// `find_plugin_entries` lookups in the hot path. pub delegation_entries: HashMap, + /// Plugin name → resolved `elicit` hook entry for routes that + /// declared `require_approval(...)` / `confirm(...)` / … steps. Same + /// `name → entry` shape as `delegation_entries` — elicitation routes + /// by plugin name, not by `(kind, channel)`. Empty when the route has + /// no elicitation. + pub elicitation_entries: HashMap, } impl RouteDispatchPlan { @@ -242,6 +249,30 @@ impl RouteDispatchPlan { } } + // Resolve `elicit` entries for any plugins the route calls via an + // elicitation verb (`require_approval(...)`, `confirm(...)`, …). + // Same `name → entry` resolution as delegation — elicitation + // routes by plugin name, not `(kind, channel)`. + for name in collect_elicit_plugin_names(route) { + let entries = manager + .build_override_entries(&name, None, None, None) + .await; + let elicit_entry = entries + .into_iter() + .find(|(hook_name, _)| hook_name == HOOK_ELICIT); + if let Some((_, entry)) = elicit_entry { + plan.elicitation_entries.insert(name, entry); + } else { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL route references elicitation plugin not registered under \ + elicit hook — `require_approval(...)`/`confirm(...)` step will \ + fail at dispatch", + ); + } + } + plan } @@ -328,6 +359,27 @@ pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut visit = |e: &Effect| { + if let Effect::Elicit(es) = e { + if seen.insert(es.plugin_name.clone()) { + out.push(es.plugin_name.clone()); + } + } + }; + walk_effects(&route.policy, &mut visit); + walk_effects(&route.post_policy, &mut visit); + out +} + /// Walk a `CompiledRoute` and return the unique plugin names referenced /// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including /// nested) or `Stage::Plugin` (in `args` / `result` pipelines). @@ -401,6 +453,26 @@ pub(crate) fn route_capability_union( } } } + // Elicitation steps (`require_approval(name, ...)`, …) — same reason + // as delegation: an elicitation handler that declares e.g. + // `read_subject` (to read the approver identity) must not have it + // stripped at the AplRouteHandler boundary. + let elicit_plugins = collect_elicit_plugin_names(route); + for name in &elicit_plugins { + if let Some(eff) = EffectivePlugin::resolve(name, registry, &route.plugin_overrides) { + for cap in eff.capabilities.as_slice() { + caps.insert(cap.clone()); + } + } + } + // A route with an elicitation step needs `read_headers` on the synthetic + // handler so the `X-Policy-Elicitation-Id` retry header survives the + // capability filter and reaches the bag (Phase 5 retry-seeding). Without + // it, the `http` extension is stripped before the handler reads it and + // every retry re-dispatches a fresh elicitation instead of checking. + if !elicit_plugins.is_empty() { + caps.insert("read_headers".to_string()); + } caps } diff --git a/crates/apl-cpex/src/elicitation_invoker.rs b/crates/apl-cpex/src/elicitation_invoker.rs new file mode 100644 index 00000000..3b1569f5 --- /dev/null +++ b/crates/apl-cpex/src/elicitation_invoker.rs @@ -0,0 +1,232 @@ +// Location: ./crates/apl-cpex/src/elicitation_invoker.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ElicitationPluginInvoker` — `apl-core::ElicitationInvoker` impl +// bound to the `ElicitationHook` family. Drives dispatch off a +// pre-resolved [`RouteDispatchPlan::elicitation_entries`] and forwards +// to `PluginManager::invoke_entries::(...)`. +// +// # When this runs +// +// The apl-core evaluator calls one of the three trait methods per +// `Effect::Elicit` it walks, across the lifetime of one elicitation: +// +// * `dispatch(step, resolved_from)` — first arrival. Builds an +// `ElicitationPayload` in `Dispatch` mode and invokes the named +// handler, which registers the intent / opens the backchannel and +// returns the correlation id + pending metadata. +// * `check(step, id)` — every retry. `Check` mode; the handler reports +// the current status without blocking. +// * `validate(step, id)` — once resolved. `Validate` mode; the handler +// verifies the response is genuine. +// +// All three resolve the handler the same way delegation does — `name → +// entry` off the plan (`step.plugin_name`). Elicitation routes by plugin +// name, not `(kind, channel)`; `channel` is only an audit label. +// +// # Shared extensions +// +// Like `DelegationPluginInvoker`, this carries the request's shared +// `Extensions` so the handler can read identity (the approver, the +// subject) through the normal hook extensions argument. Get the handle +// via `CmfPluginInvoker::extensions_arc()`. + +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use cpex_core::elicitation::{ + ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, + ElicitationStatusKind, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_core::step::{ + ElicitStep, ElicitationDispatch, ElicitationError, ElicitationInvoker, ElicitationOutcome, + ElicitationStatus, ElicitationValidation, +}; + +use crate::dispatch_plan::RouteDispatchPlan; + +/// Bridges APL elicitation-step dispatch (`require_approval(...)`, +/// `confirm(...)`, …) to CPEX `ElicitationHook` plugins. +pub struct ElicitationPluginInvoker { + manager: Arc, + /// Same `Arc>` as the CMF invoker for this request, + /// so the handler reads the same identity the policy evaluated against. + extensions: Arc>, + /// Pre-resolved per-route elicitation lineup (`name → entry`). + plan: Arc, +} + +impl ElicitationPluginInvoker { + /// Construct an invoker bound to the request's shared extensions and + /// the route's pre-resolved dispatch plan. Take the extensions Arc + /// from `CmfPluginInvoker::extensions_arc()` so this and the CMF + /// invoker see the same Extensions. + pub fn new( + manager: Arc, + extensions: Arc>, + plan: Arc, + ) -> Self { + Self { + manager, + extensions, + plan, + } + } + + /// Resolve the route's `elicit` entry for `plugin_name`, or + /// `NotFound` (which the evaluator's `on_error` then handles). Routes + /// that don't reference this plugin won't have it in the plan. + fn entry_for( + &self, + plugin_name: &str, + ) -> Result { + self.plan + .elicitation_entries + .get(plugin_name) + .cloned() + .ok_or_else(|| ElicitationError::NotFound(plugin_name.to_string())) + } + + /// Common dispatch: invoke the resolved entry with `payload`, returns + /// the resolved `ElicitationPayload` on allow, or an + /// `ElicitationError` on a handler deny / missing payload. `op` names + /// the operation (`"dispatch"` / `"check"` / `"validate"`) so a + /// failure reads accurately rather than always saying "dispatch". + async fn invoke( + &self, + op: &str, + plugin_name: &str, + payload: ElicitationPayload, + ) -> Result { + let entry = self.entry_for(plugin_name)?; + let current_extensions = self.extensions.lock().await.clone(); + + let (result, _bg) = self + .manager + .invoke_entries::( + std::slice::from_ref(&entry), + payload, + current_extensions, + None, + ) + .await; + + if !result.continue_processing { + let detail = result + .violation + .map(|v| format!("{}: {}", v.code, v.reason)) + .unwrap_or_else(|| "denied without violation detail".to_string()); + return Err(ElicitationError::Handler(format!( + "{op}: plugin `{plugin_name}` halted: {detail}" + ))); + } + + ElicitationPayload::from_pipeline_result(&result).ok_or_else(|| { + ElicitationError::Handler(format!( + "{op}: plugin `{plugin_name}` returned allow but no ElicitationPayload" + )) + }) + } +} + +/// Map a step's optional channel/purpose/scope/timeout onto the payload's +/// input builders. Shared by all three operations. +fn apply_step_inputs(mut payload: ElicitationPayload, step: &ElicitStep) -> ElicitationPayload { + if let Some(purpose) = &step.purpose { + payload = payload.with_purpose(purpose.clone()); + } + if let Some(scope) = &step.scope { + payload = payload.with_scope(scope.clone()); + } + if let Some(timeout) = &step.timeout { + payload = payload.with_timeout(timeout.clone()); + } + if let Some(channel) = &step.channel { + payload = payload.with_channel(channel.clone()); + } + payload +} + +#[async_trait] +impl ElicitationInvoker for ElicitationPluginInvoker { + async fn dispatch( + &self, + step: &ElicitStep, + resolved_from: &str, + ) -> Result { + let payload = apply_step_inputs( + ElicitationPayload::new(ElicitationOp::Dispatch, step.kind.as_str(), resolved_from), + step, + ); + let out = self.invoke("dispatch", &step.plugin_name, payload).await?; + + // The handler must mint an id on dispatch. + let id = out.id.ok_or_else(|| { + ElicitationError::Handler(format!( + "dispatch: plugin `{}` dispatched without returning an id", + step.plugin_name + )) + })?; + Ok(ElicitationDispatch { + id, + approver: out.approver, + intent_id: out.intent_id, + expires_at: out.expires_at, + }) + } + + async fn check( + &self, + step: &ElicitStep, + id: &str, + ) -> Result { + let payload = apply_step_inputs( + ElicitationPayload::new(ElicitationOp::Check, step.kind.as_str(), "") + .with_elicitation_id(id), + step, + ); + let out = self.invoke("check", &step.plugin_name, payload).await?; + + match out.status { + Some(ElicitationStatusKind::Pending) | None => Ok(ElicitationStatus::Pending), + Some(ElicitationStatusKind::Expired) => Ok(ElicitationStatus::Expired), + Some(ElicitationStatusKind::Resolved) => { + // Resolved must carry an outcome; default to Denied + // (fail-safe — never silently treat as approved). + let outcome = match out.outcome { + Some(ElicitationOutcomeKind::Approved) => ElicitationOutcome::Approved, + Some(ElicitationOutcomeKind::Denied) | None => ElicitationOutcome::Denied, + }; + Ok(ElicitationStatus::Resolved { outcome }) + }, + } + } + + async fn validate( + &self, + step: &ElicitStep, + id: &str, + ) -> Result { + let payload = apply_step_inputs( + ElicitationPayload::new(ElicitationOp::Validate, step.kind.as_str(), "") + .with_elicitation_id(id), + step, + ); + let out = self.invoke("validate", &step.plugin_name, payload).await?; + + Ok(ElicitationValidation { + // Absent `valid` is treated as not-valid (fail-closed). + valid: out.valid.unwrap_or(false), + approver: out.approver, + intent_id: out.intent_id, + reason: out.reason, + }) + } +} diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs index 4afe5cf3..4479f75d 100644 --- a/crates/apl-cpex/src/lib.rs +++ b/crates/apl-cpex/src/lib.rs @@ -34,6 +34,7 @@ pub mod cmf_invoker; pub mod delegation_invoker; pub mod dispatch_plan; +pub mod elicitation_invoker; pub mod parallel_safety; pub mod pdp_router; pub mod register; @@ -45,8 +46,12 @@ pub mod visitor; pub use cmf_invoker::CmfPluginInvoker; pub use delegation_invoker::DelegationPluginInvoker; pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; +pub use elicitation_invoker::ElicitationPluginInvoker; pub use pdp_router::PdpRouter; pub use register::{register_apl, AplOptions}; -pub use route_handler::{AplRouteHandler, Phase}; +pub use route_handler::{ + AplRouteHandler, Phase, ELICITATION_APPROVED_CODE, ELICITATION_ID_HEADER, + ELICITATION_PEEK_HEADER, ELICITATION_PENDING_CODE, +}; pub use session_store::{MemorySessionStore, SessionStore, SessionStoreError, SessionStoreFactory}; pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index 51d9d7ad..8c8457bf 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -51,9 +51,36 @@ use apl_core::step::PdpResolver; use crate::cmf_invoker::CmfPluginInvoker; use crate::delegation_invoker::DelegationPluginInvoker; use crate::dispatch_plan::DispatchCache; +use crate::elicitation_invoker::ElicitationPluginInvoker; use crate::pdp_router::PdpRouter; use crate::session_store::SessionStore; +/// JSON-RPC error code the host emits when a phase suspends on a pending +/// elicitation: "request not complete — retry echoing the elicitation id." +/// In the application-reserved JSON-RPC range; carried via +/// `PluginViolation::proto_error_code` for the host to put on the wire. +/// The agent SDK keys its pause/resume loop on this code. +pub const ELICITATION_PENDING_CODE: i64 = -32120; + +/// Header an agent echoes on retry to continue a suspended elicitation — +/// its value is the `elicitation_id` from a prior `-32120`. The handler +/// seeds it into the bag (`elicitation.id`) before evaluation so the +/// runtime *checks* the existing elicitation instead of dispatching a new +/// one. Mirrors how `X-User-Token` carries request-scoped context. +pub const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id"; + +/// JSON-RPC error code emitted when an agent re-checks an approval in +/// *peek* mode and it has resolved approved: "approved — confirm to apply." +/// The phase does NOT forward to the tool; the agent confirms with the +/// requester and re-sends *without* the peek header to actually run it. +/// Lets a human authorize while the requester separately commits execution. +pub const ELICITATION_APPROVED_CODE: i64 = -32121; + +/// Header an agent sets (alongside `X-Policy-Elicitation-Id`) to *peek* at an +/// approval — resolve its status without committing the action. Truthy +/// value ("1"/"true"/anything non-empty) enables it. +pub const ELICITATION_PEEK_HEADER: &str = "X-Policy-Elicitation-Peek"; + /// Which APL phase this handler runs. Pre covers `args` + `policy`; Post /// covers `result` + `post_invocation`. Set once at construction and never /// changes. @@ -245,6 +272,16 @@ impl AnyHookHandler for AplRouteHandler { .with_route_key(&self.route.route_key) .build(); + // Phase 5 retry seeding: if the agent echoed an elicitation id (from + // a prior `-32120`) in the `X-Policy-Elicitation-Id` header, seed it + // into the bag *before* evaluation. `dispatch_elicitation` then takes + // the "id present → check" path (poll the existing approval) instead + // of dispatching a fresh one. Without this, every retry would open a + // new approval and the loop would never resolve. + if let Some(elicitation_id) = elicitation_id_from_headers(&post_extensions) { + bag.set(apl_core::step::elicitation_bag_keys::ID, elicitation_id); + } + // Build `RoutePayload.args` from the message. Per-content shape: // * ToolCall → arguments map (JSON Object) // * PromptRequest → arguments map (JSON Object) @@ -310,8 +347,21 @@ impl AnyHookHandler for AplRouteHandler { // erased forms get borrowed into `evaluate_pre`/`evaluate_post`; // `dispatch_parallel` can then `Arc::clone` an owned 'static // reference into each branch closure. + // Elicitation bridge — resolves `require_approval(...)` / + // `confirm(...)` steps to `ElicitationHook` plugins by name off + // the same plan, sharing the request's Extensions so the handler + // reads the same identity. Routes with no elicitation steps have + // an empty `elicitation_entries` map; an accidental `Effect::Elicit` + // then returns `NotFound`, handled by the step's `on_error`. + let elicitations = Arc::new(ElicitationPluginInvoker::new( + Arc::clone(&manager), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + let invoker_dyn: Arc = invoker.clone(); let delegations_dyn: Arc = delegations.clone(); + let elicitations_dyn: Arc = elicitations.clone(); let decision = match self.phase { Phase::Pre => { @@ -322,6 +372,7 @@ impl AnyHookHandler for AplRouteHandler { &self.pdp, &invoker_dyn, &delegations_dyn, + &elicitations_dyn, ) .await }, @@ -333,6 +384,7 @@ impl AnyHookHandler for AplRouteHandler { &self.pdp, &invoker_dyn, &delegations_dyn, + &elicitations_dyn, ) .await }, @@ -411,6 +463,12 @@ impl AnyHookHandler for AplRouteHandler { None }; + // A suspended phase reports `Allow` with a pending bundle — it + // must NOT forward. Fail closed with a distinguished violation that + // carries the elicitation id (mapped to JSON-RPC `-32120`) so the + // suspend is visible and the unapproved call never proceeds. + let pending_elicitation = decision.pending.clone(); + // 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 @@ -435,6 +493,36 @@ impl AnyHookHandler for AplRouteHandler { }, }; + if let Some(p) = &pending_elicitation { + tracing::info!( + route = %self.route.route_key, + elicitation_id = %p.id, + plugin = %p.plugin_name, + "policy suspended on pending elicitation; emitting -32120 (retry)" + ); + // The phase suspended awaiting a human. Do NOT forward. Surface + // a structured "request not complete — retry echoing this id" + // via the protocol error code the host maps to the wire + // (JSON-RPC `-32120`). Left undecorated by `denyWith` — it is a + // retry signal, not a denial. + continue_processing = false; + violation = Some(pending_violation(p)); + } + + // Peek (confirm-then-apply): the agent re-checked an approval but + // asked NOT to commit yet (the `X-Policy-Elicitation-Peek` header). If + // the elicitation resolved approved (Allow, not pending), report + // "approved — confirm to apply" (-32121) and do NOT forward. The + // agent then asks the requester, who re-sends without the peek header + // to actually run the tool (the plugin replays the cached approval). + if continue_processing + && elicitation_peek_from_headers(&post_extensions) + && bag.get_string(apl_core::step::elicitation_bag_keys::OUTCOME) == Some("approved") + { + continue_processing = false; + violation = Some(approved_peek_violation(&bag)); + } + // Append fail-closed with merge precedence: // - decision Allow + append Err → flip to Deny with a // distinguished `session.persist_failed` violation. @@ -665,3 +753,137 @@ fn extensions_changed(before: &Extensions, after: &Extensions) -> bool { }; security_changed || delegation_changed || raw_creds_changed } + +// --------------------------------------------------------------------- +// Phase 5: pending elicitation ↔ wire (`-32120`) +// --------------------------------------------------------------------- + +/// Extract the elicitation id an agent echoes on retry from the +/// `X-Policy-Elicitation-Id` request header. `None` when absent/empty. +/// Pure so it's unit-testable without the full handler path. +fn elicitation_id_from_headers(ext: &Extensions) -> Option { + ext.http + .as_ref() + .and_then(|h| h.get_request_header(ELICITATION_ID_HEADER)) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +/// True when the agent set `X-Policy-Elicitation-Peek` to a truthy value — +/// it wants to resolve the approval's status without committing the action. +fn elicitation_peek_from_headers(ext: &Extensions) -> bool { + ext.http + .as_ref() + .and_then(|h| h.get_request_header(ELICITATION_PEEK_HEADER)) + .is_some_and(|v| !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0") +} + +/// Build the `-32121` "approved — confirm to apply" violation for a peek +/// that resolved approved. Carries the elicitation id + approver in +/// `details` so the agent can ask the requester and then re-send (without +/// the peek header) to actually run the tool. +fn approved_peek_violation(bag: &apl_core::attributes::AttributeBag) -> PluginViolation { + use apl_core::step::elicitation_bag_keys as bk; + let mut details: std::collections::HashMap = std::collections::HashMap::new(); + if let Some(id) = bag.get_string(bk::ID) { + details.insert("elicitation_id".into(), Value::String(id.to_string())); + } + if let Some(approver) = bag.get_string(bk::APPROVER) { + details.insert("approver".into(), Value::String(approver.to_string())); + } + PluginViolation::new( + "elicitation.approved", + "approved — confirm to apply (re-send without the peek header)".to_string(), + ) + .with_proto_error_code(ELICITATION_APPROVED_CODE) + .with_details(details) +} + +/// Build the `-32120` violation for a suspended phase: a distinguished +/// code, the protocol error code the host maps to the wire, and the +/// elicitation bundle in `details` so the agent can show who's approving / +/// when it expires and retry by re-sending the id. +fn pending_violation(p: &apl_core::step::PendingElicitation) -> PluginViolation { + let mut details: std::collections::HashMap = std::collections::HashMap::new(); + details.insert("elicitation_id".into(), Value::String(p.id.clone())); + details.insert("plugin".into(), Value::String(p.plugin_name.clone())); + for (key, val) in [ + ("approver", &p.approver), + ("channel", &p.channel), + ("expires_at", &p.expires_at), + ("intent_id", &p.intent_id), + ] { + if let Some(v) = val { + details.insert(key.into(), Value::String(v.clone())); + } + } + PluginViolation::new( + "elicitation.pending", + format!( + "awaiting approval `{}` via `{}` — retry with this id", + p.id, p.plugin_name + ), + ) + .with_proto_error_code(ELICITATION_PENDING_CODE) + .with_details(details) +} + +#[cfg(test)] +mod phase5_tests { + use super::*; + use cpex_core::extensions::HttpExtension; + use std::sync::Arc; + + fn pending(id: &str) -> apl_core::step::PendingElicitation { + apl_core::step::PendingElicitation { + id: id.to_string(), + plugin_name: "manager-approver".to_string(), + approver: Some("alice".to_string()), + intent_id: None, + channel: Some("ciba".to_string()), + expires_at: Some("2026-12-31T00:00:00Z".to_string()), + source: "route.payroll.policy[0]".to_string(), + } + } + + #[test] + fn pending_violation_carries_minus32120_and_bundle() { + let v = pending_violation(&pending("elic-1")); + assert_eq!(v.proto_error_code, Some(ELICITATION_PENDING_CODE)); + assert_eq!(v.code, "elicitation.pending"); + assert_eq!(v.details.get("elicitation_id").unwrap(), "elic-1"); + assert_eq!(v.details.get("approver").unwrap(), "alice"); + assert_eq!(v.details.get("channel").unwrap(), "ciba"); + assert_eq!(v.details.get("expires_at").unwrap(), "2026-12-31T00:00:00Z"); + // Absent optional → not in details. + assert!(!v.details.contains_key("intent_id")); + } + + #[test] + fn elicitation_id_extracted_from_header_case_insensitively() { + let mut http = HttpExtension::default(); + http.set_request_header("x-policy-elicitation-id", "elic-42"); + let ext = Extensions { + http: Some(Arc::new(http)), + ..Extensions::default() + }; + assert_eq!( + elicitation_id_from_headers(&ext).as_deref(), + Some("elic-42") + ); + } + + #[test] + fn no_header_yields_none() { + // No http extension at all. + assert!(elicitation_id_from_headers(&Extensions::default()).is_none()); + // Header present but empty → treated as absent. + let mut http = HttpExtension::default(); + http.set_request_header(ELICITATION_ID_HEADER, ""); + let ext = Extensions { + http: Some(Arc::new(http)), + ..Extensions::default() + }; + assert!(elicitation_id_from_headers(&ext).is_none()); + } +} diff --git a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs index bb550d6d..90a260c2 100644 --- a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -51,6 +51,7 @@ fn plan_for( Arc::new(RouteDispatchPlan { plugins, delegation_entries: Default::default(), + elicitation_entries: Default::default(), }) } @@ -515,6 +516,7 @@ fn plan_with_narrowed_caps( Arc::new(apl_cpex::RouteDispatchPlan { plugins, delegation_entries: Default::default(), + elicitation_entries: Default::default(), }) } diff --git a/crates/apl-cpex/tests/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs index 05e87ae8..2808185a 100644 --- a/crates/apl-cpex/tests/delegate_step_e2e.rs +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -351,6 +351,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; @@ -475,6 +476,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; @@ -567,6 +569,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; @@ -678,6 +681,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; @@ -799,6 +803,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; @@ -901,6 +906,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(delegations.clone() as Arc), + &(Arc::new(apl_core::NoopElicitationInvoker) as Arc), ) .await; diff --git a/crates/apl-cpex/tests/elicit_step_e2e.rs b/crates/apl-cpex/tests/elicit_step_e2e.rs new file mode 100644 index 00000000..8d3468fd --- /dev/null +++ b/crates/apl-cpex/tests/elicit_step_e2e.rs @@ -0,0 +1,353 @@ +// Location: ./crates/apl-cpex/tests/elicit_step_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for the elicitation bridge (Phase 2). +// +// Verifies the full flow: +// * apl-cpex's `RouteDispatchPlan::build` resolves an elicitation +// plugin's `elicit` entry into `plan.elicitation_entries` by name. +// * `ElicitationPluginInvoker` builds an `ElicitationPayload` for each +// of dispatch / check / validate (setting `ElicitationOp`), +// dispatches via `invoke_entries::(...)`, and maps +// the returned payload back to apl-core's `ElicitationDispatch` / +// `ElicitationStatus` / `ElicitationValidation`. +// * A handler deny surfaces as `ElicitationError`. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use cpex_core::context::PluginContext; +use cpex_core::elicitation::{ + ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, + ElicitationStatusKind, HOOK_ELICIT, +}; +use cpex_core::error::PluginViolation; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +use apl_core::{ + compile_config, ElicitKind, ElicitStep, ElicitationInvoker, ElicitationOutcome, + ElicitationStatus, +}; +use apl_cpex::{ElicitationPluginInvoker, RouteDispatchPlan}; + +use tokio::sync::Mutex as AsyncMutex; + +// --------------------------------------------------------------------- +// Fake ElicitationHook plugin — records each operation it saw and +// produces a configurable response per operation. +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +struct OpRecord { + op: ElicitationOp, + kind: String, + from: String, + elicitation_id: Option, + purpose: Option, +} + +struct FakeApprover { + cfg: PluginConfig, + ledger: Arc>>, + /// What `check` reports. + check_status: ElicitationStatusKind, + check_outcome: Option, + /// What `validate` reports. + validate_valid: bool, + /// When `Some`, the handler denies (any op) with this violation code. + deny_code: Option, +} + +#[async_trait] +impl Plugin for FakeApprover { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for FakeApprover { + async fn handle( + &self, + payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger.lock().unwrap().push(OpRecord { + op: payload.operation(), + kind: payload.kind().to_string(), + from: payload.from().to_string(), + elicitation_id: payload.elicitation_id().map(str::to_string), + purpose: payload.purpose().map(str::to_string), + }); + + if let Some(code) = &self.deny_code { + return PluginResult::deny(PluginViolation::new( + code.clone(), + "fake-approver denied".to_string(), + )); + } + + let mut out = payload.clone(); + match payload.operation() { + ElicitationOp::Dispatch => { + out.id = Some("elic-abc".to_string()); + out.status = Some(ElicitationStatusKind::Pending); + out.approver = Some(payload.from().to_string()); + out.intent_id = Some("intent-77".to_string()); + out.expires_at = Some("2026-12-31T00:00:00Z".to_string()); + }, + ElicitationOp::Check => { + out.status = Some(self.check_status); + out.outcome = self.check_outcome; + }, + ElicitationOp::Validate => { + out.valid = Some(self.validate_valid); + out.approver = Some("alice@example.com".to_string()); + out.intent_id = Some("intent-77".to_string()); + }, + } + PluginResult::modify_payload(out) + } +} + +fn approver_cfg(name: &str) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_ELICIT.to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: std::collections::HashSet::new(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +/// Build a manager with the plugin registered, compile the route YAML, +/// and build the dispatch plan for the named route. +async fn setup( + plugin: Arc, + cfg: PluginConfig, +) -> (Arc, Arc) { + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler::(plugin, cfg) + .expect("register elicitation plugin"); + mgr.initialize().await.expect("initialize"); + + let yaml = r#" +plugins: + - name: manager-approver + kind: test + hooks: [elicit] +routes: + payroll_adjust: + pre_invocation: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" +"#; + let cfg = compile_config(yaml).expect("compile route YAML"); + let route = cfg.routes.get("payroll_adjust").expect("route present"); + let plan = RouteDispatchPlan::build(route, &cfg.plugins, &mgr).await; + (mgr, Arc::new(plan)) +} + +fn elicit_step() -> ElicitStep { + ElicitStep { + kind: ElicitKind::Approval, + plugin_name: "manager-approver".to_string(), + channel: Some("ciba".to_string()), + from: "claim.manager".to_string(), + purpose: Some("Approve raise".to_string()), + scope: Some("args.amount <= 25000".to_string()), + timeout: None, + config_override: None, + on_error: None, + source: "payroll_adjust.policy[0]".to_string(), + } +} + +fn invoker(mgr: Arc, plan: Arc) -> ElicitationPluginInvoker { + ElicitationPluginInvoker::new(mgr, Arc::new(AsyncMutex::new(Extensions::default())), plan) +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +#[tokio::test] +async fn dispatch_maps_payload_outputs_and_passes_resolved_from() { + let ledger = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::clone(&ledger), + check_status: ElicitationStatusKind::Pending, + check_outcome: None, + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + + let d = inv + .dispatch(&elicit_step(), "alice@example.com") + .await + .expect("dispatch ok"); + + assert_eq!(d.id, "elic-abc"); + assert_eq!(d.approver.as_deref(), Some("alice@example.com")); + assert_eq!(d.intent_id.as_deref(), Some("intent-77")); + assert_eq!(d.expires_at.as_deref(), Some("2026-12-31T00:00:00Z")); + + // The plugin saw a Dispatch op with the resolved approver + inputs. + let rec = ledger.lock().unwrap()[0].clone(); + assert_eq!(rec.op, ElicitationOp::Dispatch); + assert_eq!(rec.kind, "approval"); + assert_eq!(rec.from, "alice@example.com"); + assert_eq!(rec.purpose.as_deref(), Some("Approve raise")); + assert!(rec.elicitation_id.is_none()); +} + +#[tokio::test] +async fn check_maps_pending_and_resolved() { + // Pending. + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Pending, + check_outcome: None, + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + let s = inv + .check(&elicit_step(), "elic-abc") + .await + .expect("check ok"); + assert_eq!(s, ElicitationStatus::Pending); + + // Resolved + approved. + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Resolved, + check_outcome: Some(ElicitationOutcomeKind::Approved), + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + let s = inv + .check(&elicit_step(), "elic-abc") + .await + .expect("check ok"); + assert_eq!( + s, + ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Approved + } + ); +} + +#[tokio::test] +async fn check_resolved_without_outcome_defaults_denied() { + // Fail-safe: Resolved with no outcome must not read as approved. + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Resolved, + check_outcome: None, + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + let s = inv + .check(&elicit_step(), "elic-abc") + .await + .expect("check ok"); + assert_eq!( + s, + ElicitationStatus::Resolved { + outcome: ElicitationOutcome::Denied + } + ); +} + +#[tokio::test] +async fn validate_maps_verdict_and_facts() { + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Resolved, + check_outcome: Some(ElicitationOutcomeKind::Approved), + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + let v = inv + .validate(&elicit_step(), "elic-abc") + .await + .expect("validate ok"); + assert!(v.valid); + assert_eq!(v.approver.as_deref(), Some("alice@example.com")); + assert_eq!(v.intent_id.as_deref(), Some("intent-77")); +} + +#[tokio::test] +async fn handler_deny_surfaces_as_error() { + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Pending, + check_outcome: None, + validate_valid: false, + deny_code: Some("channel.unavailable".to_string()), + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + let err = inv + .dispatch(&elicit_step(), "alice@example.com") + .await + .expect_err("handler denied"); + let msg = format!("{err}"); + assert!(msg.contains("channel.unavailable"), "got: {msg}"); +} + +#[tokio::test] +async fn unregistered_plugin_is_not_found() { + // Plan has no entry for a plugin the step names → NotFound, which the + // evaluator's on_error then handles. + let plugin = Arc::new(FakeApprover { + cfg: approver_cfg("manager-approver"), + ledger: Arc::new(Mutex::new(Vec::new())), + check_status: ElicitationStatusKind::Pending, + check_outcome: None, + validate_valid: true, + deny_code: None, + }); + let (mgr, plan) = setup(Arc::clone(&plugin), approver_cfg("manager-approver")).await; + let inv = invoker(mgr, plan); + + let mut step = elicit_step(); + step.plugin_name = "nonexistent".to_string(); + let err = inv + .dispatch(&step, "alice@example.com") + .await + .expect_err("unregistered"); + assert!(matches!( + err, + apl_core::ElicitationError::NotFound(p) if p == "nonexistent" + )); +} diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs index eea3824e..a7ff63cb 100644 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -32,8 +32,8 @@ use cpex_core::plugin::{Plugin, PluginConfig}; use apl_core::pipeline::TaintScope; use apl_core::{ - compile_config, evaluate_route, AttributeBag, Decision, NoopDelegationInvoker, PdpCall, - PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, + compile_config, evaluate_route, AttributeBag, Decision, NoopDelegationInvoker, + NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, }; use apl_cpex::{ @@ -248,6 +248,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc), + &(Arc::new(NoopElicitationInvoker) as Arc), ) .await; @@ -299,6 +300,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc), + &(Arc::new(NoopElicitationInvoker) as Arc), ) .await; @@ -425,6 +427,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc), + &(Arc::new(NoopElicitationInvoker) as Arc), ) .await; @@ -518,6 +521,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc), + &(Arc::new(NoopElicitationInvoker) as Arc), ) .await; assert_eq!(decision.decision, Decision::Allow); @@ -577,6 +581,7 @@ routes: &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc), + &(Arc::new(NoopElicitationInvoker) as Arc), ) .await; assert_eq!(decision.decision, Decision::Allow); diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml index f8856da4..bd0723c9 100644 --- a/crates/cpex-builtins/Cargo.toml +++ b/crates/cpex-builtins/Cargo.toml @@ -29,16 +29,17 @@ rust-version.workspace = true [features] # The common in-process set: the four hook plugins plus both PDPs. Heavier # / external-backend extensions (valkey) stay opt-in via `full`. -default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "cedar-direct", "cel"] +default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "elicitation-ciba", "cedar-direct", "cel"] # Each feature pulls exactly one builtin crate. -pii-scanner = ["dep:cpex-plugin-pii-scanner"] -audit-logger = ["dep:cpex-plugin-audit-logger"] -identity-jwt = ["dep:cpex-plugin-identity-jwt"] -delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] -cedar-direct = ["dep:cpex-pdp-cedar-direct"] -cel = ["dep:cpex-pdp-cel"] -valkey = ["dep:cpex-session-valkey"] +pii-scanner = ["dep:cpex-plugin-pii-scanner"] +audit-logger = ["dep:cpex-plugin-audit-logger"] +identity-jwt = ["dep:cpex-plugin-identity-jwt"] +delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] +elicitation-ciba = ["dep:cpex-plugin-elicitation-ciba"] +cedar-direct = ["dep:cpex-pdp-cedar-direct"] +cel = ["dep:cpex-pdp-cel"] +valkey = ["dep:cpex-session-valkey"] # Everything wired here, including the Valkey session store (redis client + # rustls TLS stack). @@ -55,6 +56,7 @@ cpex-plugin-pii-scanner = { workspace = true, optional = true } cpex-plugin-audit-logger = { workspace = true, optional = true } cpex-plugin-identity-jwt = { workspace = true, optional = true } cpex-plugin-delegator-oauth = { workspace = true, optional = true } +cpex-plugin-elicitation-ciba = { workspace = true, optional = true } cpex-pdp-cedar-direct = { workspace = true, optional = true } cpex-pdp-cel = { workspace = true, optional = true } cpex-session-valkey = { workspace = true, optional = true } diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs index 6206423b..46276552 100644 --- a/crates/cpex-builtins/src/lib.rs +++ b/crates/cpex-builtins/src/lib.rs @@ -46,6 +46,8 @@ pub use cpex_pdp_cel::CelPdpFactory; pub use cpex_plugin_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; #[cfg(feature = "delegator-oauth")] pub use cpex_plugin_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; +#[cfg(feature = "elicitation-ciba")] +pub use cpex_plugin_elicitation_ciba::{CibaApproverFactory, KIND as CIBA_KIND}; #[cfg(feature = "identity-jwt")] pub use cpex_plugin_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; #[cfg(feature = "pii-scanner")] @@ -88,10 +90,11 @@ macro_rules! register_builtins { } register_builtins! { - feature "identity-jwt" => cpex_plugin_identity_jwt::JwtIdentityFactory, - feature "delegator-oauth" => cpex_plugin_delegator_oauth::OAuthDelegatorFactory, - feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, - feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, + feature "identity-jwt" => cpex_plugin_identity_jwt::JwtIdentityFactory, + feature "delegator-oauth" => cpex_plugin_delegator_oauth::OAuthDelegatorFactory, + feature "elicitation-ciba" => cpex_plugin_elicitation_ciba::CibaApproverFactory, + feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, + feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, } // ----------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/elicitation/hook.rs b/crates/cpex-core/src/elicitation/hook.rs new file mode 100644 index 00000000..0ad9d94c --- /dev/null +++ b/crates/cpex-core/src/elicitation/hook.rs @@ -0,0 +1,78 @@ +// Location: ./crates/cpex-core/src/elicitation/hook.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ElicitationHook` — the `HookTypeDef` marker for the Elicitation hook +// family. Plugins implement `HookHandler`; the apl-cpex +// bridge dispatches into it to drive a human-in-the-loop step (approval, +// confirmation, step-up, …). +// +// Single hook name: `"elicit"`. The three touch-points (dispatch / +// check / validate) are NOT separate hook names — they share one +// payload shape and are distinguished by `ElicitationPayload::operation`. +// This keeps registration and the plan trivial: one plugin, one entry, +// resolved `name → entry` exactly like delegation's `token.delegate`. + +use crate::hooks::trait_def::PluginResult; + +use super::payload::ElicitationPayload; + +/// Hook name for Elicitation handlers. +pub const HOOK_ELICIT: &str = "elicit"; + +crate::define_hook! { + /// Elicitation hook — drives a human-in-the-loop step through a + /// channel plugin (Keycloak CIBA, Slack, in-band, …). + /// + /// **Payload** ([`ElicitationPayload`]) — unified input + accumulator. + /// The apl-cpex bridge sets the input fields (`operation`, + /// `elicitation_id`, `kind`, `from`, `purpose`, `scope`, `timeout`, + /// `channel`) and invokes the hook; the handler populates the output + /// fields (`id`, `status`, `outcome`, `approver`, `intent_id`, + /// `expires_at`, `valid`, `reason`) on a clone of the running payload + /// and returns it via [`PluginResult::modify_payload`]. Input fields + /// are private and read through accessors. + /// + /// **Result** ([`PluginResult`][PluginResult]) — + /// the executor's standard envelope. `modified_payload` carries the + /// updated payload. `continue_processing = false` halts (the handler + /// could not service the operation — e.g. unknown channel error); the + /// bridge maps that to an `ElicitationError`. + /// + /// **Three operations, one hook.** [`ElicitationPayload::operation`] + /// tells the handler whether this is a `Dispatch`, `Check`, or + /// `Validate` call. A handler typically `match`es on it. The three + /// short, synchronous calls span the (possibly hours-long) human gap, + /// which is owned by the channel — never by a handler call. + /// + /// **Handler signature:** + /// + /// ```rust,ignore + /// impl HookHandler for CibaApprover { + /// async fn handle( + /// &self, + /// payload: &ElicitationPayload, + /// _ext: &Extensions, + /// _ctx: &mut PluginContext, + /// ) -> PluginResult { + /// let mut out = payload.clone(); + /// match payload.operation() { + /// ElicitationOp::Dispatch => { /* register intent + CIBA backchannel */ } + /// ElicitationOp::Check => { /* poll Keycloak token endpoint */ } + /// ElicitationOp::Validate => { /* verify token + intent binding */ } + /// } + /// PluginResult::modify_payload(out) + /// } + /// } + /// ``` + /// + /// **Registration:** + /// `manager.register_handler_for_names::(plugin, config, &["elicit"])`. + /// + /// [PluginResult]: crate::hooks::trait_def::PluginResult + ElicitationHook, "elicit" => { + payload: ElicitationPayload, + result: PluginResult, + } +} diff --git a/crates/cpex-core/src/elicitation/mod.rs b/crates/cpex-core/src/elicitation/mod.rs new file mode 100644 index 00000000..340f9417 --- /dev/null +++ b/crates/cpex-core/src/elicitation/mod.rs @@ -0,0 +1,22 @@ +// Location: ./crates/cpex-core/src/elicitation/mod.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Elicitation hook family — human-in-the-loop dispatch (approval, +// confirmation, step-up, attestation, …). +// +// Mirrors the delegation/ module layout: the hook marker (via the +// generic hooks layer) plus the hook-specific payload + enums. No +// executor wiring needed — dispatch is free via +// `mgr.invoke_entries::`. The apl-cpex bridge fills the +// payload and maps the result back to apl-core's `ElicitationInvoker` +// return types. + +pub mod hook; +pub mod payload; + +pub use hook::{ElicitationHook, HOOK_ELICIT}; +pub use payload::{ + ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, ElicitationStatusKind, +}; diff --git a/crates/cpex-core/src/elicitation/payload.rs b/crates/cpex-core/src/elicitation/payload.rs new file mode 100644 index 00000000..7cfeaf23 --- /dev/null +++ b/crates/cpex-core/src/elicitation/payload.rs @@ -0,0 +1,343 @@ +// Location: ./crates/cpex-core/src/elicitation/payload.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ElicitationPayload` — the unified state struct threaded through the +// Elicitation hook chain. Same input/accumulator split as +// `DelegationPayload`: +// +// * **Input** (private — bridge-supplied, never mutated by handlers) — +// `operation`, `elicitation_id`, `kind`, `from`, `purpose`, `scope`, +// `timeout`, `channel`. Set once by the apl-cpex bridge before +// `invoke_entries::`. Read through accessors; no +// setters or mutable field access at the module boundary. +// +// * **Accumulating output** (`pub` fields) — `id`, `status`, `outcome`, +// `approver`, `intent_id`, `expires_at`, `valid`, `reason`, +// `metadata`. Handlers clone the payload, populate the slots relevant +// to the `operation`, and return it via `PluginResult::modify_payload`. +// +// # Three operations, one payload +// +// Unlike delegation (one mint per call), an elicitation has three +// touch-points across its lifetime — dispatch / check / validate. They +// share this one payload shape and differ only in which `operation` the +// bridge sets and which output slots the handler fills: +// +// * `Dispatch` → handler registers the intent / opens the channel +// backchannel, fills `id` / `approver` / `intent_id` / +// `expires_at` / `status = Pending`. +// * `Check` → handler reads current state, fills `status` (and +// `outcome` when resolved). +// * `Validate` → handler verifies the response is genuine, fills +// `valid` / `approver` / `intent_id` / `reason`. +// +// The hours-long human gap lives in the channel (e.g. Keycloak CIBA), +// never in a handler call — each operation is short and synchronous. +// +// # Decoupling from apl-core +// +// cpex-core does not depend on apl-core, so this module defines its own +// `ElicitationOp` / `ElicitationStatusKind` / `ElicitationOutcomeKind` +// rather than reusing apl-core's `ElicitKind` / `ElicitationStatus`. The +// apl-cpex bridge maps between the two. `kind` is a free string +// (`"approval"`, `"confirm"`, …) — the per-kind *validation contract* is +// the apl-core runtime's job, so the handler only needs it informationally. +// +// # Rejection +// +// Same as delegation: handlers reject via +// `PluginResult::deny(PluginViolation::new(code, reason))`. The executor +// halts the chain and the bridge maps that to an `ElicitationError`. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::executor::PipelineResult; +use crate::impl_plugin_payload; + +/// Which of the three elicitation touch-points this invocation is. The +/// handler dispatches on it to decide what to do and which output slots +/// to fill. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationOp { + /// First arrival — register the intent / open the backchannel. + Dispatch, + /// Retry — read current status without blocking. + Check, + /// Resolved — verify the response is genuine. + Validate, +} + +/// Current state of a dispatched elicitation, reported by a `Check` +/// handler. Mirrors apl-core's `ElicitationStatus` shape without the +/// dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationStatusKind { + /// The human has not responded yet. + Pending, + /// The human responded — see `outcome` for approved/denied. + Resolved, + /// The elicitation timed out before a response. + Expired, +} + +/// The human's decision once an elicitation resolves. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationOutcomeKind { + Approved, + Denied, +} + +/// State threaded through the Elicitation hook chain. See the +/// module-level docs for the input/accumulator split. Input fields are +/// private (set once via the constructor + builders, never mutated). +/// Output fields are `pub` (handlers populate on clones and return the +/// updated payload). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationPayload { + // ----- Input (private — bridge-supplied, never mutated by handlers) ----- + /// Which touch-point this is. + operation: ElicitationOp, + + /// Correlation id from a prior `Dispatch`. `None` on dispatch (the + /// handler mints it); `Some` on check / validate. + elicitation_id: Option, + + /// Elicitation kind (`"approval"`, `"confirm"`, `"step_up"`, …) — + /// informational for the handler; the validation contract is enforced + /// by the apl-core runtime, not here. + kind: String, + + /// Resolved approver identity (the apl-core `from` attr already + /// resolved against the request bag by the bridge). For CIBA this is + /// the `login_hint`. + from: String, + + /// Human-readable description of what's being asked — CIBA + /// `binding_message`, audited verbatim. `None` for kinds that carry + /// their prompt elsewhere. + purpose: Option, + + /// The APL scope expression string, passed through for the handler to + /// record alongside the registered intent (the runtime evaluates it, + /// not the handler). `None` for kinds without arg binding. + scope: Option, + + /// Validity window (e.g. `"24h"`) — CIBA `requested_expiry`. `None` + /// defers to the handler's configured default. + timeout: Option, + + /// Optional channel label (`"ciba"` / `"slack"` / …) for the handler's + /// own logging/telemetry. Not a routing key (the plugin was already + /// selected by name). + channel: Option, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// Correlation id minted on `Dispatch`. The agent echoes it on retry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Current status — set by `Check` (and `Dispatch`, which leaves it + /// `Pending`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Approved / denied — set by `Check` once `status` is `Resolved`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + + /// Resolved approver identity — set by `Dispatch` (when known) and by + /// `Validate` (the consenting party, cross-checked against `from`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approver: Option, + + /// Registered intent id (lodging-intent binding) — set by `Dispatch` + /// and echoed by `Validate` for audit reconciliation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent_id: Option, + + /// Expiry timestamp (RFC 3339) — set by `Dispatch` when the channel + /// reports one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + + /// Genuineness verdict — set by `Validate`. `true` when the signed + /// response validates, its intent binding matches, and the responder + /// is the approver. (The runtime layers the scope-over-args check on + /// top before honoring an approval.) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid: Option, + + /// Why a `Check`/`Validate` reported the state it did — failure reason + /// when `valid` is `false`, or diagnostic context. `None` on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + + /// Optional handler metadata (telemetry, diagnostics). Not load-bearing. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} + +impl ElicitationPayload { + /// Construct a payload for the given operation + kind + resolved + /// approver. Optional input slots are set via the `.with_*` builders; + /// output fields start empty and accumulate as the handler runs. + pub fn new(operation: ElicitationOp, kind: impl Into, from: impl Into) -> Self { + Self { + operation, + elicitation_id: None, + kind: kind.into(), + from: from.into(), + purpose: None, + scope: None, + timeout: None, + channel: None, + id: None, + status: None, + outcome: None, + approver: None, + intent_id: None, + expires_at: None, + valid: None, + reason: None, + metadata: HashMap::new(), + } + } + + // -------- Input builders -------- + + /// Set the correlation id (check / validate operations). + pub fn with_elicitation_id(mut self, id: impl Into) -> Self { + self.elicitation_id = Some(id.into()); + self + } + + pub fn with_purpose(mut self, purpose: impl Into) -> Self { + self.purpose = Some(purpose.into()); + self + } + + pub fn with_scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } + + pub fn with_timeout(mut self, timeout: impl Into) -> Self { + self.timeout = Some(timeout.into()); + self + } + + pub fn with_channel(mut self, channel: impl Into) -> Self { + self.channel = Some(channel.into()); + self + } + + // -------- Input read accessors -------- + + pub fn operation(&self) -> ElicitationOp { + self.operation + } + + pub fn elicitation_id(&self) -> Option<&str> { + self.elicitation_id.as_deref() + } + + pub fn kind(&self) -> &str { + &self.kind + } + + pub fn from(&self) -> &str { + &self.from + } + + pub fn purpose(&self) -> Option<&str> { + self.purpose.as_deref() + } + + pub fn scope(&self) -> Option<&str> { + self.scope.as_deref() + } + + pub fn timeout(&self) -> Option<&str> { + self.timeout.as_deref() + } + + pub fn channel(&self) -> Option<&str> { + self.channel.as_deref() + } + + // -------- Host-side application helper -------- + + /// Pull the resolved `ElicitationPayload` out of a `PipelineResult` + /// returned by `mgr.invoke_entries::(...)`. Returns + /// `None` when the pipeline was denied or the result's payload wasn't + /// an `ElicitationPayload`. Same contract as + /// `DelegationPayload::from_pipeline_result`. + pub fn from_pipeline_result(result: &PipelineResult) -> Option { + result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .cloned() + } +} + +impl_plugin_payload!(ElicitationPayload); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_sets_input_and_leaves_output_empty() { + let p = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com") + .with_purpose("Approve $25,000 raise") + .with_scope("args.amount <= 25000") + .with_timeout("24h") + .with_channel("ciba"); + + assert_eq!(p.operation(), ElicitationOp::Dispatch); + assert_eq!(p.kind(), "approval"); + assert_eq!(p.from(), "alice@example.com"); + assert_eq!(p.purpose(), Some("Approve $25,000 raise")); + assert_eq!(p.scope(), Some("args.amount <= 25000")); + assert_eq!(p.timeout(), Some("24h")); + assert_eq!(p.channel(), Some("ciba")); + assert!(p.elicitation_id().is_none()); + // Output slots start empty. + assert!(p.id.is_none()); + assert!(p.status.is_none()); + assert!(p.valid.is_none()); + } + + #[test] + fn with_elicitation_id_sets_correlation() { + let p = ElicitationPayload::new(ElicitationOp::Check, "approval", "alice@example.com") + .with_elicitation_id("elic-123"); + assert_eq!(p.elicitation_id(), Some("elic-123")); + assert_eq!(p.operation(), ElicitationOp::Check); + } + + #[test] + fn payload_roundtrips_through_serde() { + // The executor clones payloads across handler boundaries via serde + // in some paths — confirm a populated output survives a round-trip. + let mut p = + ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com"); + p.id = Some("elic-1".into()); + p.status = Some(ElicitationStatusKind::Pending); + p.intent_id = Some("intent-9".into()); + + let json = serde_json::to_string(&p).unwrap(); + let back: ElicitationPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id.as_deref(), Some("elic-1")); + assert_eq!(back.status, Some(ElicitationStatusKind::Pending)); + assert_eq!(back.intent_id.as_deref(), Some("intent-9")); + } +} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index 12378bfd..fae7bd25 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -24,12 +24,15 @@ // workload resolution from raw credentials) // - [`delegation`] — TokenDelegate hook family (outbound credential // minting for downstream calls) +// - [`elicitation`] — Elicitation hook family (human-in-the-loop: +// approval, confirmation, step-up, …) // - [`error`] — Error types, violations, and result types pub mod cmf; pub mod config; pub mod context; pub mod delegation; +pub mod elicitation; pub mod error; pub mod executor; pub mod extensions; diff --git a/crates/cpex/Cargo.toml b/crates/cpex/Cargo.toml index daf52b84..f56198e8 100644 --- a/crates/cpex/Cargo.toml +++ b/crates/cpex/Cargo.toml @@ -56,6 +56,7 @@ jwt = ["cpex-builtins/identity-jwt"] oauth = ["cpex-builtins/delegator-oauth"] pii = ["cpex-builtins/pii-scanner"] audit = ["cpex-builtins/audit-logger"] +elicitation-ciba = ["cpex-builtins/elicitation-ciba"] cedar = ["cpex-builtins/cedar-direct"] cel = ["cpex-builtins/cel"] valkey = ["cpex-builtins/valkey"] diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index e826471c..7d997eb4 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -15,6 +15,7 @@ This page covers the configuration: routes, phases, predicates, rules, and field - [PDP Integration]({{< relref "/docs/apl/pdp" >}}): hand a decision to Cedar, CEL, or an external engine. - [Identity & IdP]({{< relref "/docs/apl/identity" >}}): how callers are resolved into the attributes predicates read. - [Delegation]({{< relref "/docs/apl/delegation" >}}): mint scoped downstream credentials via token exchange. +- [Elicitation]({{< relref "/docs/apl/elicitation" >}}): pause an operation for human approval and resume on retry. - [Session Tainting]({{< relref "/docs/apl/tainting" >}}): information-flow control across requests. ## Routes and phases diff --git a/docs/content/docs/apl/effects.md b/docs/content/docs/apl/effects.md index 55ea540f..e7c96be6 100644 --- a/docs/content/docs/apl/effects.md +++ b/docs/content/docs/apl/effects.md @@ -15,6 +15,7 @@ An APL rule does something. That something is an **effect**. Effects are the bui | `deny` / `deny('reason')` / `deny('reason', 'code')` | Halt the phase and all later phases with a violation. | | `plugin(name)` (alias `run(name)`) | Invoke a registered plugin (PII scan, audit log, custom check). | | `delegate(name, ...)` | Mint a downstream credential via a delegator plugin. See [Delegation]({{< relref "/docs/apl/delegation" >}}). | +| `require_approval(name, ...)` / `confirm(...)` / `require_step_up(...)` / `require_attestation(...)` / `request_info(...)` / `require_review(...)` | Ask a human and suspend the operation until they respond. See [Elicitation]({{< relref "/docs/apl/elicitation" >}}). | | `taint(label[, scope])` | Attach a label to the session or message. See [Session Tainting]({{< relref "/docs/apl/tainting" >}}). | | field pipelines | Validate or transform `args`/`result` fields. See [APL]({{< relref "/docs/apl" >}}). | | PDP call (`cedar:`, `cel:`, `opa(...)`) | Delegate the decision to a policy engine. See [PDP Integration]({{< relref "/docs/apl/pdp" >}}). | @@ -73,4 +74,4 @@ authorization: ## Phases recap -Effects run within the four route phases: `args`, `authorization.pre_invocation`, `result`, `authorization.post_invocation` (see [APL]({{< relref "/docs/apl" >}})). `delegate` and PDP calls belong in `pre_invocation` or `post_invocation`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. +Effects run within the four route phases: `args`, `authorization.pre_invocation`, `result`, `authorization.post_invocation` (see [APL]({{< relref "/docs/apl" >}})). `delegate`, elicitation verbs, and PDP calls belong in `pre_invocation` or `post_invocation`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. An elicitation verb can also *suspend* a phase — the operation neither allows nor denies, but pauses for a human and resumes on retry (see [Elicitation]({{< relref "/docs/apl/elicitation" >}})). diff --git a/docs/content/docs/apl/elicitation.md b/docs/content/docs/apl/elicitation.md new file mode 100644 index 00000000..6da3246f --- /dev/null +++ b/docs/content/docs/apl/elicitation.md @@ -0,0 +1,105 @@ +--- +title: "Elicitation" +weight: 45 +--- + +# Human-in-the-Loop Elicitation + +Some operations should not proceed on the caller's say-so alone. A large payroll change wants a manager's approval; a destructive action wants the user to confirm; a sensitive read wants a fresh second factor. Elicitation lets a policy **pause an operation to ask a human** and resume once they respond — without blocking the request path, and with the decision bound to the actual request rather than to an LLM's paraphrase of it. + +## The requirement + +The scenario's `approve_raise` may run only after the requester's manager approves, and the approval must cover the specific amount being requested. The manager is not the caller, may take hours to respond, and their answer must be genuine — a signed decision from the right person, not a value the agent asserts. CPEX must dispatch the ask, hold the operation open across the agent's retries, and verify the response before letting the tool run. + +## Elicitation as an effect + +Elicitation is an effect in the `authorization.pre_invocation` phase. A sugar verb names an elicitation handler plugin and who to ask: + +```yaml +plugins: + - name: manager-approver + kind: elicitation/ciba + hooks: [elicit] + +routes: + - tool: approve_raise + pre_invocation: + - "require(authenticated)" + - when: "args.amount > 10000" + do: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" +``` + +- `from` is **who to ask** — an attribute reference resolved against the request bag (here `claim.manager`, the requester's manager, who differs from the subject). An attribute `from` that doesn't resolve fails closed rather than dispatching to a bogus identity. +- `scope` is the **args binding** — an APL boolean expression the runtime checks against the live request when the response comes back. +- `purpose` is the **audited, human-readable** description of what is being asked. + +The other verbs (`confirm`, `require_step_up`, `require_attestation`, `request_info`, `require_review`) parse to the same effect with a different kind; each selects the validation contract the runtime applies to the response. + +## The model: suspend and resume + +An elicitation has three short, synchronous touch-points. The hours-long human gap lives in the **channel** (e.g. Keycloak CIBA), never in a blocking call: + +1. **Dispatch** — first arrival: register the intent, open the channel backchannel, return a correlation id. +2. **Check** — on each agent retry: read status (`pending` / `resolved` / `expired`) without blocking. +3. **Validate** — once resolved: verify the response is *genuine*, then the runtime layers the `scope`-over-args *sufficiency* check before honoring the approval. + +While pending, the phase **suspends** rather than denies. The decision stays `Allow`, but a pending marker rides alongside it, and the host maps that to JSON-RPC **`-32120`** ("not complete — retry echoing this id"). The forward rule is one clause: *forward only when the decision is `Allow` and nothing is pending.* Expiry, channel error, a genuine denial, or a failed validation all fail closed (default `on_error: deny`). + +```mermaid +flowchart LR + REQ["agent request"] --> DISP["require_approval(manager-approver)"] + DISP -->|"dispatch: open backchannel"| OP["Keycloak CIBA"] + DISP -->|"-32120 + elicitation id"| REQ + REQ -->|"retry, echo id"| CHK["check status"] + CHK -->|"pending"| REQ + CHK -->|"resolved"| VAL["validate genuineness
+ scope over live args"] + VAL -->|"approved & sufficient"| TOOL["forward to tool"] + VAL -->|"denied / expired / invalid"| DENY["deny (fail closed)"] +``` + +## The CIBA channel plugin + +The bundled `elicitation/ciba` plugin drives the ask through OpenID Connect CIBA (Client-Initiated Backchannel Authentication) against Keycloak or any CIBA-capable OP: + +```yaml +plugins: + - name: manager-approver + kind: elicitation/ciba + hooks: [elicit] + config: + backchannel_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/ext/ciba/auth" + token_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/token" + client_id: "cpex-gateway" + client_secret_source: + kind: file + path: /etc/cpex-secrets/client-secret + approver_claim: preferred_username +``` + +`from` becomes the CIBA `login_hint`, `purpose` seeds the `binding_message`, and `timeout` maps to `requested_expiry`. Dispatch returns an `auth_req_id` that doubles as the elicitation id the agent echoes on retry. + +## Elicitation attributes + +Dispatch and resolution write `elicitation.*` attributes that later rules in the same phase — and the audit log — can read: + +| Attribute | Meaning | +|-----------|---------| +| `elicitation.id` | Correlation id the agent echoes on retry. | +| `elicitation.status` | `pending` / `resolved` / `expired`. | +| `elicitation.outcome` | `approved` / `denied`, once resolved. | +| `elicitation.approver` | Resolved approver identity, cross-checked against `from`. | +| `elicitation.channel` | Audit label for how the human was reached (not a routing key). | + +## A note on genuineness and args binding + +Two independent checks stand between an approval and the tool call, and it is worth understanding where each is enforced: + +- **Genuineness** is the channel plugin's job. For CIBA, the approver identity is extracted from the token the OP returns and cross-checked against the `login_hint`. The plugin trusts the token because it comes straight from the OP over a client-authenticated TLS poll — it does **not** independently verify the JWT signature. That trust therefore rests on the token endpoint being reached over correctly configured TLS with client authentication; deploy accordingly (always `https://`, real client credentials) and do not point a CIBA handler at a plaintext or unauthenticated endpoint outside local development. +- **Sufficiency** is the runtime's job. Keycloak has no RFC 9396 rich authorization request, so the binding between "what was approved" and "what is being executed" lives in APL: the `scope:` expression is evaluated against the live request args at validation. A human can approve, but if the args drift outside `scope` (e.g. the amount was raised after approval), the operation fails closed regardless. + +The `purpose` is recorded verbatim as the source of truth for what was approved — it is never derived from model output. + +## How it connects to the pipeline + +`require_approval(...)` and its sibling verbs dispatch to a plugin implementing the `elicit` hook, resolved by name off the route's dispatch plan exactly like `delegate(...)`. Because elicitation is an explicit, sequenced effect — gated behind authentication, checked on every retry, and validated before the forward — a pending or unapproved operation never reaches the tool.