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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
71 changes: 71 additions & 0 deletions builtins/plugins/elicitation-ciba/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading