From dc656542c54821a04b6b60a5408b0fdf2da64cea Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 15 Oct 2026 03:05:16 -0400 Subject: [PATCH 1/2] feat(ffi): return delegated credentials to hosts that opt in Signed-off-by: Frederico Araujo --- CHANGELOG.md | 4 + Cargo.lock | 1 + crates/cpex-ffi/Cargo.toml | 1 + crates/cpex-ffi/src/apl.rs | 17 ++- crates/cpex-ffi/src/lib.rs | 244 ++++++++++++++++++++++++++++++++++++ go/cpex/README.md | 2 + go/cpex/acceptance_test.go | 30 +++++ go/cpex/apl.go | 15 +++ go/cpex/credentials_test.go | 64 ++++++++++ go/cpex/engine.go | 13 +- go/cpex/types.go | 59 ++++++++- 11 files changed, 439 insertions(+), 11 deletions(-) create mode 100644 go/cpex/credentials_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd120d4..afde094f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- Host opt-in for delegated credentials: `cpex_apl_install` flag `CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS` (Go `WithDelegatedCredentials()`) returns the credentials `delegate()` minted on allowed results as `delegated_credentials` (Go `PipelineResult.DelegatedCredentials`, value redacted when printed). Additive; the C ABI stays at 3. + ## [0.3.0] - 2026-10-14 CPEX now runs on the [praxis-policy engine](https://github.com/praxis-proxy/policy) (PPE) 0.5.0 from crates.io instead of its own engine crates. API names, hook names, several config keys, defaults and security checks change. The [migration guide](docs/content/docs/migration-0.3.md) lists every change by surface. diff --git a/Cargo.lock b/Cargo.lock index bdaeb3db..8ec75776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ version = "0.3.0" dependencies = [ "async-trait", "aws-lc-rs", + "chrono", "cpex", "jsonwebtoken", "praxis-policy-core", diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index b80b6f68..c92678ef 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -48,6 +48,7 @@ default = [] [dev-dependencies] async-trait = { workspace = true } +chrono = { workspace = true } # Mint test JWTs and their JWKS for the identity-jwt egress tests. The key is # generated at test time, so no private key is checked in. jsonwebtoken = { version = "11", default-features = false, features = ["aws_lc_rs"] } diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index 899dee02..ded67d1b 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -51,10 +51,17 @@ pub(crate) enum InstallState { /// host decision; no policy document can turn it on. pub const CPEX_APL_ALLOW_PRIVATE_DESTINATIONS: u32 = 1; +/// `cpex_apl_install` flag: return the delegated credentials minted by +/// `delegate()` steps on allowed results (`delegated_credentials`), so the +/// host can attach them to the upstream request. Off by default: without +/// it no token bytes leave Rust. +pub const CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS: u32 = 2; + /// Every flag bit this build understands. Any other bit is rejected with /// `RC_INVALID_INPUT`, so a host built against a newer flag set fails /// loudly instead of silently losing a setting. -pub const CPEX_APL_KNOWN_FLAGS: u32 = CPEX_APL_ALLOW_PRIVATE_DESTINATIONS; +pub const CPEX_APL_KNOWN_FLAGS: u32 = + CPEX_APL_ALLOW_PRIVATE_DESTINATIONS | CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS; /// Install the bundled builtins, the outbound HTTP transport, and the /// secret providers on `handle`. @@ -73,7 +80,9 @@ pub const CPEX_APL_KNOWN_FLAGS: u32 = CPEX_APL_ALLOW_PRIVATE_DESTINATIONS; /// `flags` is a bit set of `CPEX_APL_*` values. With /// `CPEX_APL_ALLOW_PRIVATE_DESTINATIONS` set, the transport may reach /// private and loopback addresses; without it, such requests are refused -/// and builtins report their egress-denied codes. +/// and builtins report their egress-denied codes. With +/// `CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS` set, allowed results carry the +/// delegated credentials minted for the request. /// /// Idempotent per handle: once a call has succeeded, later calls do /// nothing and return `RC_OK`, whatever their flags. A call that panicked @@ -139,6 +148,10 @@ pub unsafe extern "C" fn cpex_apl_install(handle: *const CpexEngineInner, flags: match result { Ok(()) => { + inner.expose_delegated_credentials.store( + flags & CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS != 0, + std::sync::atomic::Ordering::Release, + ); *state = InstallState::Installed; RC_OK }, diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 902429ba..34da1344 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -456,6 +456,10 @@ pub struct CpexEngineInner { /// mutex rather than an atomic so a concurrent second call waits for /// the first to finish instead of returning before the builtins exist. install_state: Mutex, + /// Set by `cpex_apl_install` with `CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS`: + /// allowed results then carry the delegated credentials minted for the + /// request in `delegated_credentials`. + pub(crate) expose_delegated_credentials: std::sync::atomic::AtomicBool, /// Test-only: makes the next `cpex_apl_install` panic inside its /// catch_unwind, to reach the failed state. #[cfg(test)] @@ -468,6 +472,7 @@ impl CpexEngineInner { Self { engine, install_state: Mutex::new(apl::InstallState::NotInstalled), + expose_delegated_credentials: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] fail_install: std::sync::atomic::AtomicBool::new(false), } @@ -991,6 +996,8 @@ pub unsafe extern "C" fn cpex_invoke( .as_ref() .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + let delegated_credentials = host_delegated_credentials(inner, &result); + // Build FFI result. `errors` flows through verbatim — it's already // PluginErrorRecord which is the canonical wire shape. let ffi_result = FfiPipelineResult { @@ -1002,6 +1009,7 @@ pub unsafe extern "C" fn cpex_invoke( modified_payload: modified_payload_bytes, payload_modified: result.payload_modified, modified_extensions: modified_extensions_bytes, + delegated_credentials, }; let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { @@ -1194,6 +1202,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( // caller, who guarantees they are writable. return unsafe { finish_pipeline_result( + inner, id_result, None, payload_type, @@ -1227,6 +1236,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( // who guarantees they are writable. unsafe { finish_pipeline_result( + inner, result, Some(bg), payload_type, @@ -1248,6 +1258,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( /// Out pointers must be writable. Returns `RC_OK` on success or /// `RC_SERIALIZE_ERROR` if the result can't be MessagePack-encoded. unsafe fn finish_pipeline_result( + inner: &CpexEngineInner, mut result: praxis_policy_core::executor::PipelineResult, bg: Option, payload_type: u8, @@ -1282,6 +1293,7 @@ unsafe fn finish_pipeline_result( .modified_extensions .as_ref() .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + let delegated_credentials = host_delegated_credentials(inner, &result); let ffi_result = FfiPipelineResult { continue_processing: result.continue_processing, @@ -1292,6 +1304,7 @@ unsafe fn finish_pipeline_result( modified_payload: modified_payload_bytes, payload_modified: result.payload_modified, modified_extensions: modified_extensions_bytes, + delegated_credentials, }; let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { @@ -1482,6 +1495,87 @@ struct FfiPipelineResult { #[serde(skip_serializing_if = "Option::is_none", default)] #[serde(with = "serde_bytes_opt")] modified_extensions: Option>, + /// Delegated credentials minted for this request, for the host to + /// attach upstream. Empty unless the host opted in at + /// `cpex_apl_install` and the pipeline allowed the request. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + delegated_credentials: Vec, +} + +/// One minted delegated credential as the host must send it: `value` is +/// the full header value, scheme prefix included. This is the only FFI +/// output that carries token bytes; `Extensions.raw_credentials` keeps +/// them skip-serialized. +#[derive(serde::Serialize, serde::Deserialize)] +struct FfiDelegatedCredential { + /// Outbound header name, e.g. `Authorization`. + header: String, + /// Header value: prefix + token, e.g. `Bearer eyJ...`. + value: String, + /// Delegator plugin instance that minted it. + delegator: String, + /// Logical target named by the `delegate(...)` step. + target: String, + audience: String, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + scopes: Vec, + /// RFC 3339 expiry. + expires_at: String, +} + +impl std::fmt::Debug for FfiDelegatedCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FfiDelegatedCredential") + .field("header", &self.header) + .field("value", &"[REDACTED]") + .field("delegator", &self.delegator) + .field("target", &self.target) + .field("audience", &self.audience) + .field("scopes", &self.scopes) + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Collect the delegated credentials an allowed result hands the host, +/// sorted for a stable order. Nothing unless the host opted in. A token +/// with no bytes (never minted in this process) is dropped: an empty +/// bearer must not reach an upstream. +fn host_delegated_credentials( + inner: &CpexEngineInner, + result: &praxis_policy_core::executor::PipelineResult, +) -> Vec { + let exposed = inner + .expose_delegated_credentials + .load(std::sync::atomic::Ordering::Acquire); + if !exposed || !result.continue_processing { + return Vec::new(); + } + let Some(raw) = result + .modified_extensions + .as_ref() + .and_then(|e| e.raw_credentials.as_ref()) + else { + return Vec::new(); + }; + let mut out: Vec = raw + .delegated_tokens + .iter() + .filter(|(_, t)| !t.token.trim().is_empty()) + .map(|(k, t)| FfiDelegatedCredential { + header: t.outbound_header.clone(), + value: format!("{}{}", t.outbound_prefix, t.token.as_str()), + delegator: k.delegator_name.clone(), + target: k.target_name.clone(), + audience: t.audience.clone(), + scopes: t.scopes.clone(), + expires_at: t.expires_at.to_rfc3339(), + }) + .collect(); + out.sort_by(|a, b| { + (&a.target, &a.audience, &a.header).cmp(&(&b.target, &b.audience, &b.header)) + }); + out } /// Helper for serializing `Option>` as binary in MessagePack. @@ -2220,4 +2314,154 @@ routes: cpex_shutdown(twice); } } + + mod delegated_credentials { + use super::*; + use praxis_policy_core::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + }; + + const MINTED: &str = "ffi-test-only-minted-token"; + + fn token(value: &str, prefix: &str) -> RawDelegatedToken { + RawDelegatedToken::new( + value, + "Authorization", + "workday-api", + vec!["read".to_string()], + chrono::Utc::now() + chrono::Duration::minutes(5), + ) + .with_outbound_prefix(prefix) + } + + fn key(target: &str) -> DelegationKey { + DelegationKey::new(DelegationMode::OnBehalfOfUser, "workday-api", vec![]) + .with_subject_id("alice") + .with_delegator_name("workday-oauth") + .with_target_name(target) + } + + fn extensions(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions { + let mut raw = RawCredentialsExtension::default(); + raw.delegated_tokens.extend(tokens); + Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + } + } + + fn allowed(ext: Extensions) -> praxis_policy_core::executor::PipelineResult { + praxis_policy_core::executor::PipelineResult::allowed_with( + Box::new(GenericPayload { + value: serde_json::Value::Null, + }), + ext, + PluginContextTable::default(), + ) + } + + fn inner(expose: bool) -> CpexEngineInner { + let inner = CpexEngineInner::new(Arc::new(PolicyEngine::default())); + inner + .expose_delegated_credentials + .store(expose, std::sync::atomic::Ordering::Release); + inner + } + + #[test] + fn exposed_on_allow_with_prefix_and_metadata() { + let res = allowed(extensions(vec![ + (key("workday-api"), token(MINTED, "Bearer ")), + (key("github-api"), token("gh-only", "token ")), + ])); + let creds = host_delegated_credentials(&inner(true), &res); + assert_eq!(creds.len(), 2); + assert_eq!(creds[0].target, "github-api"); + assert_eq!(creds[0].value, "token gh-only"); + let c = &creds[1]; + assert_eq!(c.header, "Authorization"); + assert_eq!(c.value, format!("Bearer {MINTED}")); + assert_eq!(c.delegator, "workday-oauth"); + assert_eq!(c.audience, "workday-api"); + assert_eq!(c.scopes, vec!["read".to_string()]); + assert!(chrono::DateTime::parse_from_rfc3339(&c.expires_at).is_ok()); + } + + #[test] + fn withheld_without_opt_in_or_on_deny() { + let ext = || extensions(vec![(key("workday-api"), token(MINTED, "Bearer "))]); + assert!(host_delegated_credentials(&inner(false), &allowed(ext())).is_empty()); + let denied = praxis_policy_core::executor::PipelineResult::denied( + praxis_policy_core::error::PluginViolation::new("denied", "test"), + ext(), + PluginContextTable::default(), + ); + assert!(host_delegated_credentials(&inner(true), &denied).is_empty()); + } + + #[test] + fn empty_token_is_dropped() { + let res = allowed(extensions(vec![( + key("workday-api"), + token(" ", "Bearer "), + )])); + assert!(host_delegated_credentials(&inner(true), &res).is_empty()); + } + + #[test] + fn debug_redacts_value_and_wire_omits_empty() { + let res = allowed(extensions(vec![( + key("workday-api"), + token(MINTED, "Bearer "), + )])); + let creds = host_delegated_credentials(&inner(true), &res); + let printed = format!("{creds:?}"); + assert!(!printed.contains(MINTED), "{printed}"); + assert!(printed.contains("[REDACTED]")); + + // The modified_extensions channel still carries no token bytes. + let ext_bytes = + rmp_serde::to_vec_named(res.modified_extensions.as_ref().unwrap()).unwrap(); + assert!( + !ext_bytes + .windows(MINTED.len()) + .any(|w| w == MINTED.as_bytes()) + ); + + let none = allowed(Extensions::default()); + let bytes = rmp_serde::to_vec_named(&FfiPipelineResult { + continue_processing: true, + violation: None, + errors: vec![], + metadata: None, + payload_type: PAYLOAD_GENERIC, + modified_payload: None, + payload_modified: false, + modified_extensions: None, + delegated_credentials: host_delegated_credentials(&inner(true), &none), + }) + .unwrap(); + let decoded: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap(); + assert!(decoded.get("delegated_credentials").is_none()); + } + + #[test] + fn apl_install_records_the_flag() { + unsafe { + for (flags, want) in [ + (0, false), + (crate::apl::CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS, true), + ] { + let h = cpex_engine_new_default(); + assert_eq!(crate::apl::cpex_apl_install(h, flags), RC_OK); + assert_eq!( + (*h).expose_delegated_credentials + .load(std::sync::atomic::Ordering::Acquire), + want + ); + cpex_shutdown(h); + } + } + } + } } diff --git a/go/cpex/README.md b/go/cpex/README.md index 0383963d..cfc4412c 100644 --- a/go/cpex/README.md +++ b/go/cpex/README.md @@ -104,6 +104,8 @@ The transport refuses private, loopback and link-local destinations by default. `WithPrivateDestinations` is engine-wide. It lets every outbound builtin (JWKS, token exchange, CIBA, Vault) reach loopback, RFC 1918, link-local addresses including cloud metadata (`169.254.169.254`), and CGNAT. Use it for local development and demos only. A blocked JWKS fetch shows up as `auth.jwks_unavailable`; a blocked token exchange as `delegation.egress_denied`. +A `delegate()` step mints its credential inside the engine, and by default the token never leaves it. A host that forwards the request itself passes `cpex.WithDelegatedCredentials()`; allowed results then carry `PipelineResult.DelegatedCredentials`, each with the header name, the full header value (`Value.Reveal()`, scheme prefix included), target, audience, scopes and expiry. `Value` is a `SecretValue`, which prints, marshals and logs as `[REDACTED]`. A library that predates the option rejects it with `ErrCpexInvalidInput`. + `LoadConfig` returns a typed error without the engine's message. The message, naming the rejected key, hook or plugin, goes to the FFI's `tracing` log. ## Secrets diff --git a/go/cpex/acceptance_test.go b/go/cpex/acceptance_test.go index 9a1c8363..a7db5d73 100644 --- a/go/cpex/acceptance_test.go +++ b/go/cpex/acceptance_test.go @@ -381,6 +381,36 @@ routes: } wantDenied(t, "audience refused", callTool(t, eng, alice, "read_repo", map[string]any{"repo": "cpex"}, ""), "") + + // Without the host opt-in the minted token stays in the engine. + if got := callTool(t, eng, alice, "get_compensation", map[string]any{}, ""); len(got.DelegatedCredentials) != 0 { + t.Errorf("credentials returned without WithDelegatedCredentials: %d", len(got.DelegatedCredentials)) + } + + // With it, the allowed result carries the header to send upstream. + exposed := accEngine(t, yaml, WithPrivateDestinations(), WithDelegatedCredentials()) + res := callTool(t, exposed, alice, "get_compensation", map[string]any{"employee_id": "e-1"}, "") + wantAllowed(t, "hr delegated, exposed", res) + if len(res.DelegatedCredentials) != 1 { + t.Fatalf("expected one delegated credential, got %d", len(res.DelegatedCredentials)) + } + c := res.DelegatedCredentials[0] + if c.Header != "Authorization" || c.Value.Reveal() != "Bearer acceptance-only-workday-token" { + t.Errorf("credential = %s: ", c.Header, len(c.Value.Reveal())) + } + if c.Delegator != "workday-oauth" || c.Target != "workday-api" || c.Audience != "workday-api" { + t.Errorf("credential metadata = %+v", c) + } + if exp, err := time.Parse(time.RFC3339, c.ExpiresAt); err != nil || !exp.After(time.Now()) { + t.Errorf("expires_at %q: %v", c.ExpiresAt, err) + } + if strings.Contains(fmt.Sprintf("%v %+v %#v", res, *res, c), "acceptance-only-workday-token") { + t.Error("a printed result leaked the minted token") + } + evan := mintJWT(t, "evan", []string{"engineer"}, 10*time.Minute, nil) + if got := callTool(t, exposed, evan, "get_compensation", map[string]any{}, ""); len(got.DelegatedCredentials) != 0 { + t.Error("a denied result must carry no credentials") + } } // --------------------------------------------------------------------------- diff --git a/go/cpex/apl.go b/go/cpex/apl.go index 0c2eae2b..16a3ebb4 100644 --- a/go/cpex/apl.go +++ b/go/cpex/apl.go @@ -35,6 +35,10 @@ import "C" // in crates/cpex-ffi/src/apl.rs. const aplAllowPrivateDestinations uint32 = 1 +// aplExposeDelegatedCredentials mirrors +// CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS in crates/cpex-ffi/src/apl.rs. +const aplExposeDelegatedCredentials uint32 = 2 + // APLOption configures EnableAPL. type APLOption func(*aplSettings) @@ -53,6 +57,17 @@ func WithPrivateDestinations() APLOption { } } +// WithDelegatedCredentials returns the credentials delegate() steps mint +// on allowed results, in PipelineResult.DelegatedCredentials, so the host +// can attach them upstream. Without it the token bytes never leave the +// engine. A library that predates this option rejects it with +// ErrCpexInvalidInput. +func WithDelegatedCredentials() APLOption { + return func(s *aplSettings) { + s.flags |= aplExposeDelegatedCredentials + } +} + // aplFlags folds options into the cpex_apl_install flag word. func aplFlags(opts ...APLOption) uint32 { var s aplSettings diff --git a/go/cpex/credentials_test.go b/go/cpex/credentials_test.go new file mode 100644 index 00000000..7ee03375 --- /dev/null +++ b/go/cpex/credentials_test.go @@ -0,0 +1,64 @@ +// Location: ./go/cpex/credentials_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package cpex + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +const testMinted = "Bearer test-only-minted-token" + +func TestDelegatedCredentialDecodesFromResult(t *testing.T) { + wire, err := msgpack.Marshal(map[string]any{ + "continue_processing": true, + "payload_type": 0, + "payload_modified": false, + "delegated_credentials": []map[string]any{{ + "header": "Authorization", "value": testMinted, "delegator": "oauth", + "target": "api", "audience": "api", "expires_at": "2026-01-01T00:00:00Z", + }}, + }) + if err != nil { + t.Fatal(err) + } + var res PipelineResult + if err := msgpack.Unmarshal(wire, &res); err != nil { + t.Fatal(err) + } + if len(res.DelegatedCredentials) != 1 || res.DelegatedCredentials[0].Value.Reveal() != testMinted { + t.Fatalf("decoded %d credentials", len(res.DelegatedCredentials)) + } +} + +func TestSecretValueRedacts(t *testing.T) { + c := DelegatedCredential{Header: "Authorization", Value: SecretValue(testMinted), Target: "api"} + res := &PipelineResult{DelegatedCredentials: []DelegatedCredential{c}} + + var out bytes.Buffer + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { + fmt.Fprintf(&out, verb+"|", c.Value) + fmt.Fprintf(&out, verb+"|", c) + fmt.Fprintf(&out, verb+"|", res) + } + fmt.Fprint(&out, c.Value.String(), c.Value.GoString()) + j, _ := json.Marshal(res) + out.Write(j) + slog.New(slog.NewJSONHandler(&out, nil)).Info("x", "cred", c, "value", c.Value, "res", res) + slog.New(slog.NewTextHandler(&out, nil)).Info("x", "cred", c, "value", c.Value) + + if strings.Contains(out.String(), "test-only-minted-token") { + t.Fatalf("secret leaked: %s", out.String()) + } + if !strings.Contains(out.String(), "[REDACTED]") { + t.Fatal("no redaction marker") + } +} diff --git a/go/cpex/engine.go b/go/cpex/engine.go index 4ba8e30b..03b5c483 100644 --- a/go/cpex/engine.go +++ b/go/cpex/engine.go @@ -569,12 +569,13 @@ func Invoke[P any]( } typed := &TypedPipelineResult[P]{ - ContinueProcessing: raw.ContinueProcessing, - Violation: raw.Violation, - Errors: raw.Errors, - Metadata: raw.Metadata, - PayloadType: raw.PayloadType, - PayloadModified: raw.PayloadModified, + ContinueProcessing: raw.ContinueProcessing, + Violation: raw.Violation, + Errors: raw.Errors, + Metadata: raw.Metadata, + PayloadType: raw.PayloadType, + PayloadModified: raw.PayloadModified, + DelegatedCredentials: raw.DelegatedCredentials, } // Deserialize modified payload if present diff --git a/go/cpex/types.go b/go/cpex/types.go index 531be8ee..edd3e57d 100644 --- a/go/cpex/types.go +++ b/go/cpex/types.go @@ -11,7 +11,12 @@ package cpex -import "github.com/vmihailenco/msgpack/v5" +import ( + "fmt" + "log/slog" + + "github.com/vmihailenco/msgpack/v5" +) // Extensions carries capability-gated data alongside the payload. // Serialized to/from MessagePack when crossing the FFI boundary. @@ -365,8 +370,55 @@ type PipelineResult struct { PayloadModified bool `msgpack:"payload_modified"` // Modified extensions as raw MessagePack bytes. ModifiedExtensions []byte `msgpack:"modified_extensions,omitempty"` + // DelegatedCredentials are the credentials delegate() steps minted + // for this request, for the host to attach upstream. Populated only + // on allowed results of an engine enabled WithDelegatedCredentials. + DelegatedCredentials []DelegatedCredential `msgpack:"delegated_credentials,omitempty"` +} + +// DelegatedCredential is one minted delegated credential: send it +// upstream as the header Header with the value Value.Reveal(). +type DelegatedCredential struct { + // Header is the outbound header name, for example "Authorization". + Header string `msgpack:"header"` + // Value is the full header value, scheme prefix included + // ("Bearer ...", "token ...", or a bare API key). + Value SecretValue `msgpack:"value"` + // Delegator is the plugin instance that minted it. + Delegator string `msgpack:"delegator"` + // Target is the logical target named by the delegate() step. + Target string `msgpack:"target"` + Audience string `msgpack:"audience"` + Scopes []string `msgpack:"scopes,omitempty"` + // ExpiresAt is an RFC 3339 timestamp. + ExpiresAt string `msgpack:"expires_at"` } +// SecretValue holds credential bytes. Every formatting path (fmt verbs, +// JSON and text marshalling, slog) prints a redaction marker; Reveal +// returns the value. +type SecretValue string + +const redacted = "[REDACTED]" + +// Reveal returns the secret. +func (s SecretValue) Reveal() string { return string(s) } + +// String redacts. +func (s SecretValue) String() string { return redacted } + +// GoString redacts. +func (s SecretValue) GoString() string { return redacted } + +// Format redacts under every verb. +func (s SecretValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(redacted)) } + +// MarshalText redacts, which also covers encoding/json. +func (s SecretValue) MarshalText() ([]byte, error) { return []byte(redacted), nil } + +// LogValue redacts. +func (s SecretValue) LogValue() slog.Value { return slog.StringValue(redacted) } + // TypedPipelineResult is a PipelineResult with the modified payload // and extensions deserialized into concrete Go types. type TypedPipelineResult[P any] struct { @@ -378,8 +430,9 @@ type TypedPipelineResult[P any] struct { ModifiedPayload *P // PayloadModified reports whether a plugin actually changed the // payload. See PipelineResult.PayloadModified. - PayloadModified bool - ModifiedExtensions *Extensions + PayloadModified bool + ModifiedExtensions *Extensions + DelegatedCredentials []DelegatedCredential } // IsDenied returns true if the pipeline was halted by a plugin. From 19bde93a0da8bf188d8c1fb4ec6e546adbda4c23 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 15 Oct 2026 18:29:34 -0400 Subject: [PATCH 2/2] chore: prepare release v0.3.1 Signed-off-by: Frederico Araujo --- CHANGELOG.md | 5 ++++- CONTRIBUTING.md | 12 ++++++------ Cargo.lock | 16 ++++++++-------- Cargo.toml | 8 ++++---- crates/cpex-ffi/RELEASE.md | 14 +++++++------- examples/go-demo/go.mod | 2 +- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afde094f..4ec16f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.3.1] - 2026-10-15 + ### Added - Host opt-in for delegated credentials: `cpex_apl_install` flag `CPEX_APL_EXPOSE_DELEGATED_CREDENTIALS` (Go `WithDelegatedCredentials()`) returns the credentials `delegate()` minted on allowed results as `delegated_credentials` (Go `PipelineResult.DelegatedCredentials`, value redacted when printed). Additive; the C ABI stays at 3. @@ -215,7 +217,8 @@ PPE 0.5.0 also updates CEL to 0.15, adds the `delegator/vault` builtin, validate - Initial release -[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.3.0...HEAD +[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.3.1...HEAD +[0.3.1]: https://github.com/contextforge-org/cpex/compare/0.3.0...0.3.1 [0.3.0]: https://github.com/contextforge-org/cpex/compare/0.2.3...0.3.0 [0.2.3]: https://github.com/contextforge-org/cpex/compare/0.2.2...0.2.3 [0.2.2]: https://github.com/contextforge-org/cpex/compare/0.2.1...0.2.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c9fb688..1ff60b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ and a `version`; when adding a new internal dependency, include the `version` so the crate remains publishable. `cpex-ffi` is `publish = false` (distributed as signed prebuilt artifacts). -For 0.3.0 the complete crates.io publish order is `cpex-hosts-python`, +For 0.3.1 the complete crates.io publish order is `cpex-hosts-python`, `cpex-plugin-delegator-biscuit`, `cpex`, then `cpex-python`. The facade consumes PPE 0.5.0 from crates.io; the removed CPEX core, SDK, APL and builtin crates remain at 0.2.3 and must not be added back to the release list. @@ -93,9 +93,9 @@ remain at 0.2.3 and must not be added back to the release list. Run the local preparation checks before tagging: ```bash -make release-check VERSION=0.3.0 # manifests, Go demo, PPE pins, publish set, ABI -make release-dry VERSION=0.3.0 # cargo-release preview; no changes -make release-verify VERSION=0.3.0 # lint, tests, verified .crates, FFI, examples +make release-check VERSION=0.3.1 # manifests, Go demo, PPE pins, publish set, ABI +make release-dry VERSION=0.3.1 # cargo-release preview; no changes +make release-verify VERSION=0.3.1 # lint, tests, verified .crates, FFI, examples ``` `make publish-dry` builds and verifies all four `.crate` archives in @@ -112,8 +112,8 @@ The Rust workflow packages and verifies every retained crate before any publish, and tests the facade with `python-host` enabled as well. The FFI workflow checks all five target bundles, with Go race tests on the GNU/Linux and macOS targets. Actual publishing requires a tag matching the workspace -version. `make tag VERSION=0.3.0` creates and pushes the `v0.3.0`, -`go/cpex/v0.3.0` and `0.3.0` tags; it starts publication immediately. +version. `make tag VERSION=0.3.1` creates and pushes the `v0.3.1`, +`go/cpex/v0.3.1` and `0.3.1` tags; it starts publication immediately. The broader integration checks remain separate: `make test-python-e2e` requires a compatible `CPEX_PYTHON_SOURCE` checkout, `make parity` needs diff --git a/Cargo.lock b/Cargo.lock index 8ec75776..5b6d9d24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,7 +603,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex" -version = "0.3.0" +version = "0.3.1" dependencies = [ "cpex-hosts-python", "praxis-policy", @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "cpex-demo-ffi" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "cpex-ffi", @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "cpex-ffi" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "aws-lc-rs", @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "cpex-hosts-python" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "chrono", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "cpex-parity-harness" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "aws-lc-rs", @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "cpex-plugin-delegator-biscuit" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "biscuit-auth", @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "cpex-python" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "cpex", @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "cpex-tutorial" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "cpex", diff --git a/Cargo.toml b/Cargo.toml index 84bb2a27..038ab1b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ default-members = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" # MSRV — keep in sync with rust-toolchain.toml `channel` and clippy.toml `msrv`. rust-version = "1.96" @@ -96,9 +96,9 @@ praxis-policy-core = { version = "=0.5.0" } # time cargo substitutes it for the `path`. `cargo release` keeps these in sync # with the workspace version. Keys are package names (builtins differ from their # directory names), paths are workspace-root-relative. -cpex = { path = "crates/cpex", version = "0.3.0" } -cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.3.0" } -cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.3.0", default-features = false } +cpex = { path = "crates/cpex", version = "0.3.1" } +cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.3.1" } +cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.3.1", default-features = false } # Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked # statically into host binaries, so its compiled size flows straight into diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md index d6301221..509a14c6 100644 --- a/crates/cpex-ffi/RELEASE.md +++ b/crates/cpex-ffi/RELEASE.md @@ -8,14 +8,14 @@ without needing a Rust toolchain. This document covers what is published, how to consume and verify an artifact, and the FFI ABI policy that makes the contract durable. -For CPEX 0.3.0, use the Go module tag `go/cpex/v0.3.0` together with the -`v0.3.0` FFI artifact. Both expect C ABI **3**; upgrade them together. +For CPEX 0.3.1, use the Go module tag `go/cpex/v0.3.1` together with the +`v0.3.1` FFI artifact. Both expect C ABI **3**; upgrade them together. ## Local preparation ```sh -make ffi-package VERSION=0.3.0 # host tarball + .sha256 in dist/ -make ffi-verify VERSION=0.3.0 # checksum, metadata, ABI, Go vet + race tests +make ffi-package VERSION=0.3.1 # host tarball + .sha256 in dist/ +make ffi-verify VERSION=0.3.1 # checksum, metadata, ABI, Go vet + race tests ``` Verification uses the extracted static library from a temporary SDK copy, @@ -108,7 +108,7 @@ into your build (or fetch via `raw.githubusercontent.com` pinned to a tag) and call it before `go build` / `cargo build` / etc. ```sh -export CPEX_FFI_VERSION=v0.3.0 +export CPEX_FFI_VERSION=v0.3.1 ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" go build ./... @@ -134,7 +134,7 @@ Subsequent runs against the same version + dest are no-ops. If you want to do it by hand: ```sh -VER=v0.3.0 +VER=v0.3.1 TUPLE=linux-amd64-gnu BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" @@ -168,7 +168,7 @@ unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be augmented by the env var: ```sh -ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.3.0 bash scripts/download-ffi-artifact.sh) +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.3.1 bash scripts/download-ffi-artifact.sh) CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... ``` diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod index 3781b550..4a0d1f00 100644 --- a/examples/go-demo/go.mod +++ b/examples/go-demo/go.mod @@ -2,7 +2,7 @@ module github.com/contextforge-org/cpex/examples/go-demo go 1.25.4 -require github.com/contextforge-org/cpex/go/cpex v0.3.0 +require github.com/contextforge-org/cpex/go/cpex v0.3.1 require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect