Skip to main content

SecurityExtension

Struct SecurityExtension 

Source
pub struct SecurityExtension {
    pub labels: MonotonicSet<String>,
    pub classification: Option<String>,
    pub subject: Option<SubjectExtension>,
    pub client: Option<ClientExtension>,
    pub caller_workload: Option<WorkloadIdentity>,
    pub this_workload: Option<WorkloadIdentity>,
    pub auth_method: Option<String>,
    pub objects: HashMap<String, ObjectSecurityProfile>,
    pub data: HashMap<String, DataPolicy>,
}
Expand description

Security-related extensions.

Carries security labels (monotonic add-only), classification, up to four distinct identity principals, and data-policy metadata. The four principal slots map to the identity sources documented in docs/specs/delegation-hooks-rust-spec.md §4.1:

  • subject — the user (or service-as-user) initiating the request
  • client — the OAuth client / application brokering the request
  • caller_workload — the attested workload on the inbound network peer (SPIFFE-SVID, mTLS cert chain)
  • this_workloadour own gateway’s attested identity, used for outbound calls

A request can populate any subset; identity-resolver plugins are expected to fill the slots they’re configured for. Policy authors reason about all four uniformly through the subject.* / client.* / caller_workload.* / this_workload.* bag namespaces.

Fields§

§labels: MonotonicSet<String>

Security labels (monotonic — add-only via MonotonicSet). No remove() method — enforced at compile time.

§classification: Option<String>

Data classification level.

§subject: Option<SubjectExtension>

Authenticated user identity (who is calling).

§client: Option<ClientExtension>

Authenticated OAuth client / application brokering the request. Distinct from subject — the same user can connect through different clients (first-party web, third-party integration), and policies sometimes want to gate on which.

§caller_workload: Option<WorkloadIdentity>

The inbound caller’s attested workload identity — the network peer’s SPIFFE-SVID or mTLS-attested identity. Distinct from client (the OAuth-layer identity of the application) and subject (the user). All three can be present on the same request when an agent acts on behalf of a user through our gateway, peered via mTLS.

§this_workload: Option<WorkloadIdentity>

This agent / gateway’s own workload identity — the SPIFFE-SVID or attested identity we present when making outbound calls. Populated by the host at startup, not per request.

§auth_method: Option<String>

Authentication method used (e.g., “jwt”, “mtls”, “spiffe”, “api_key”).

§objects: HashMap<String, ObjectSecurityProfile>

Object security profiles keyed by object name.

§data: HashMap<String, DataPolicy>

Data policies keyed by data element name.

Implementations§

Source§

impl SecurityExtension

Source

pub fn add_label(&mut self, label: impl Into<String>)

Add a security label (monotonic — cannot remove).

Examples found in repository?
examples/cmf_capabilities_demo.rs (line 167)
139    async fn handle(
140        &self,
141        _payload: &MessagePayload,
142        extensions: &Extensions,
143        _ctx: &mut PluginContext,
144    ) -> PluginResult<MessagePayload> {
145        // Can see HTTP (has read_headers)
146        if let Some(ref http) = extensions.http {
147            println!(
148                "  [header-injector] HTTP headers visible: {:?}",
149                http.request_headers
150            );
151        }
152
153        // Can NOT see security subject (no read_subject)
154        if let Some(ref security) = extensions.security {
155            if security.subject.is_some() {
156                println!("  [header-injector] WARNING: Subject visible (unexpected!)");
157            } else {
158                println!("  [header-injector] Security subject: not visible (no read_subject)");
159            }
160        }
161
162        // COW copy to modify — tokens propagate from the executor
163        let mut modified = extensions.cow_copy();
164
165        // Add a label via MonotonicSet (has append_labels)
166        if modified.labels_write_token.is_some() {
167            modified.security.as_mut().unwrap().add_label("PROCESSED");
168            println!("  [header-injector] Added label 'PROCESSED'");
169        }
170
171        // Inject a header via Guarded (has write_headers)
172        if let Some(ref token) = modified.http_write_token {
173            modified
174                .http
175                .as_mut()
176                .unwrap()
177                .write(token)
178                .set_header("X-Processed-By", "header-injector");
179            println!("  [header-injector] Injected header 'X-Processed-By'");
180        }
181
182        PluginResult::modify_extensions(modified)
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Plugin: AuditLogger
188// Has read_headers, read_security, read_labels capabilities.
189// Read-only — just logs what it can see.
190// ---------------------------------------------------------------------------
191
192struct AuditLogger {
193    cfg: PluginConfig,
194}
195
196#[async_trait]
197impl Plugin for AuditLogger {
198    fn config(&self) -> &PluginConfig {
199        &self.cfg
200    }
201}
202
203impl HookHandler<CmfHook> for AuditLogger {
204    async fn handle(
205        &self,
206        payload: &MessagePayload,
207        extensions: &Extensions,
208        _ctx: &mut PluginContext,
209    ) -> PluginResult<MessagePayload> {
210        let is_result = payload.message.is_tool_result();
211        let phase = if is_result { "POST" } else { "PRE" };
212
213        let tool_name = if is_result {
214            payload
215                .message
216                .get_tool_results()
217                .first()
218                .map(|tr| tr.tool_name.as_str())
219                .unwrap_or("unknown")
220        } else {
221            payload
222                .message
223                .get_tool_calls()
224                .first()
225                .map(|tc| tc.name.as_str())
226                .unwrap_or("unknown")
227        };
228
229        print!("  [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name);
230
231        if let Some(ref security) = extensions.security {
232            let labels: Vec<&String> = security.labels.iter().collect();
233            print!("labels={:?} ", labels);
234        }
235
236        if let Some(ref http) = extensions.http {
237            if let Some(req_id) = http.get_header("X-Request-ID") {
238                print!("request_id='{}' ", req_id);
239            }
240        }
241
242        if is_result {
243            let is_error = payload
244                .message
245                .get_tool_results()
246                .first()
247                .map(|tr| tr.is_error)
248                .unwrap_or(false);
249            print!("error={} ", is_error);
250        }
251
252        println!();
253        PluginResult::allow()
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Factories
259// ---------------------------------------------------------------------------
260
261struct IdentityCheckerFactory;
262impl PluginFactory for IdentityCheckerFactory {
263    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
264        let plugin = Arc::new(IdentityChecker {
265            cfg: config.clone(),
266        });
267        Ok(PluginInstance {
268            plugin: plugin.clone(),
269            handlers: vec![
270                (
271                    "cmf.tool_pre_invoke",
272                    Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin.clone())),
273                ),
274                (
275                    "cmf.tool_post_invoke",
276                    Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin)),
277                ),
278            ],
279        })
280    }
281}
282
283struct HeaderInjectorFactory;
284impl PluginFactory for HeaderInjectorFactory {
285    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
286        let plugin = Arc::new(HeaderInjector {
287            cfg: config.clone(),
288        });
289        Ok(PluginInstance {
290            plugin: plugin.clone(),
291            handlers: vec![(
292                "cmf.tool_pre_invoke",
293                Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin)),
294            )],
295        })
296    }
297}
298
299struct AuditLoggerFactory;
300impl PluginFactory for AuditLoggerFactory {
301    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
302        let plugin = Arc::new(AuditLogger {
303            cfg: config.clone(),
304        });
305        Ok(PluginInstance {
306            plugin: plugin.clone(),
307            handlers: vec![
308                (
309                    "cmf.tool_pre_invoke",
310                    Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin.clone())),
311                ),
312                (
313                    "cmf.tool_post_invoke",
314                    Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin)),
315                ),
316            ],
317        })
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Main
323// ---------------------------------------------------------------------------
324
325#[tokio::main]
326async fn main() {
327    println!("=== CMF Capabilities Demo ===\n");
328
329    // Load config from YAML file — capabilities declared per plugin
330    let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml";
331    println!("--- Loading config from {} ---\n", config_path);
332    let yaml = std::fs::read_to_string(config_path)
333        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
334    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
335
336    let mgr = PluginManager::default();
337    mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory));
338    mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory));
339    mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory));
340    mgr.load_config(cpex_config).unwrap();
341    mgr.initialize().await.unwrap();
342
343    // --- Build CMF Message ---
344    let payload = MessagePayload {
345        message: Message {
346            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
347            role: Role::Assistant,
348            content: vec![
349                ContentPart::Text {
350                    text: "Looking up compensation.".into(),
351                },
352                ContentPart::ToolCall {
353                    content: ToolCall {
354                        tool_call_id: "tc_001".into(),
355                        name: "get_compensation".into(),
356                        arguments: [("employee_id".to_string(), serde_json::json!(42))].into(),
357                        namespace: None,
358                    },
359                },
360            ],
361            channel: None,
362        },
363    };
364
365    // --- Build Extensions with security, HTTP, meta ---
366    let mut security = SecurityExtension::default();
367    security.add_label("PII");
368    security.add_label("HR_DATA");
369    security.classification = Some("confidential".into());
370    security.subject = Some(cpex_core::extensions::security::SubjectExtension {
371        id: Some("alice".into()),
372        subject_type: Some(cpex_core::extensions::security::SubjectType::User),
373        roles: ["hr_admin".to_string()].into(),
374        permissions: ["read_compensation".to_string()].into(),
375        ..Default::default()
376    });
377
378    let mut http = HttpExtension::default();
379    http.set_header("Authorization", "Bearer eyJ...");
380    http.set_header("X-Request-ID", "req-abc-123");
381
382    let ext = Extensions {
383        request: Some(Arc::new(RequestExtension {
384            environment: Some("production".into()),
385            request_id: Some("req-abc-123".into()),
386            ..Default::default()
387        })),
388        security: Some(Arc::new(security)),
389        http: Some(Arc::new(http)),
390        meta: Some(Arc::new(MetaExtension {
391            entity_type: Some("tool".into()),
392            entity_name: Some("get_compensation".into()),
393            tags: ["pii".to_string(), "hr".to_string()].into(),
394            ..Default::default()
395        })),
396        ..Default::default()
397    };
398
399    // --- Pre-invoke: type-safe dispatch via invoke_named ---
400    println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
401
402    // invoke_named<CmfHook> gives compile-time payload type checking
403    // while routing to the specific "cmf.tool_pre_invoke" hook name
404    let (pre_result, bg) = mgr
405        .invoke_named::<CmfHook>(
406            "cmf.tool_pre_invoke",
407            payload,
408            ext,
409            None, // first hook — no context table
410        )
411        .await;
412
413    println!();
414    if pre_result.continue_processing {
415        println!("Pre-invoke result: ALLOWED");
416        if let Some(ref modified_ext) = pre_result.modified_extensions {
417            if let Some(ref sec) = modified_ext.security {
418                let labels: Vec<&String> = sec.labels.iter().collect();
419                println!("  Labels after pre-invoke: {:?}", labels);
420            }
421            if let Some(ref http) = modified_ext.http {
422                println!("  Headers after pre-invoke: {:?}", http.request_headers);
423            }
424        }
425    } else {
426        println!(
427            "Pre-invoke result: DENIED — {}",
428            pre_result.violation.as_ref().unwrap().reason
429        );
430        bg.wait_for_background_tasks().await;
431        println!("\n=== Demo complete ===");
432        return;
433    }
434    bg.wait_for_background_tasks().await;
435
436    // --- Simulate tool execution ---
437    println!("\n--- Tool 'get_compensation' executes... ---");
438    println!("  Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
439
440    // --- Post-invoke: different CMF message with tool result ---
441    println!("=== Phase 2: cmf.tool_post_invoke ===\n");
442
443    let post_payload = MessagePayload {
444        message: Message {
445            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
446            role: Role::Tool,
447            content: vec![ContentPart::ToolResult {
448                content: cpex_core::cmf::ToolResult {
449                    tool_call_id: "tc_001".into(),
450                    tool_name: "get_compensation".into(),
451                    content: serde_json::json!({"salary": 150000, "currency": "USD"}),
452                    is_error: false,
453                },
454            }],
455            channel: None,
456        },
457    };
458
459    // Build post-invoke extensions — carry forward any modifications
460    // from pre-invoke via the context table
461    let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
462        // Rebuild if no modifications
463        let mut security = SecurityExtension::default();
464        security.add_label("PII");
465        Extensions {
466            security: Some(Arc::new(security)),
467            meta: Some(Arc::new(MetaExtension {
468                entity_type: Some("tool".into()),
469                entity_name: Some("get_compensation".into()),
470                ..Default::default()
471            })),
472            ..Default::default()
473        }
474    });
475
476    // Thread the context table from pre-invoke to preserve plugin state
477    let (post_result, post_bg) = mgr
478        .invoke_named::<CmfHook>(
479            "cmf.tool_post_invoke",
480            post_payload,
481            post_ext,
482            Some(pre_result.context_table),
483        )
484        .await;
485
486    println!();
487    if post_result.continue_processing {
488        println!("Post-invoke result: ALLOWED");
489    } else {
490        println!(
491            "Post-invoke result: DENIED — {}",
492            post_result.violation.as_ref().unwrap().reason
493        );
494    }
495
496    post_bg.wait_for_background_tasks().await;
497    println!("\n=== Demo complete ===");
498}
Source

pub fn has_label(&self, label: &str) -> bool

Check if a label exists (case-insensitive).

Examples found in repository?
examples/cmf_capabilities_demo.rs (line 100)
45    async fn handle(
46        &self,
47        payload: &MessagePayload,
48        extensions: &Extensions,
49        _ctx: &mut PluginContext,
50    ) -> PluginResult<MessagePayload> {
51        // Determine if this is pre or post invoke based on message content
52        let is_result = payload.message.is_tool_result();
53
54        if is_result {
55            // POST-INVOKE: verify the tool result came from an authorized call
56            let tool_name = payload
57                .message
58                .get_tool_results()
59                .first()
60                .map(|tr| tr.tool_name.as_str())
61                .unwrap_or("unknown");
62            println!(
63                "  [identity-checker] POST-INVOKE: verifying result from '{}'",
64                tool_name
65            );
66
67            if let Some(ref security) = extensions.security {
68                if let Some(ref subject) = security.subject {
69                    println!(
70                        "  [identity-checker] Result authorized for subject: {:?}",
71                        subject.id
72                    );
73                }
74            }
75            println!("  [identity-checker] POST-INVOKE ALLOWED");
76        } else {
77            // PRE-INVOKE: check caller identity and roles
78            let tool_name = payload
79                .message
80                .get_tool_calls()
81                .first()
82                .map(|tc| tc.name.as_str())
83                .unwrap_or("unknown");
84            println!(
85                "  [identity-checker] PRE-INVOKE: checking identity for '{}'",
86                tool_name
87            );
88
89            if let Some(ref security) = extensions.security {
90                let labels: Vec<&String> = security.labels.iter().collect();
91                println!("  [identity-checker] Security labels: {:?}", labels);
92
93                if let Some(ref subject) = security.subject {
94                    println!(
95                        "  [identity-checker] Subject: {:?}, Roles: {:?}",
96                        subject.id,
97                        subject.roles.iter().collect::<Vec<_>>()
98                    );
99
100                    if security.has_label("PII") && !subject.roles.contains("hr_admin") {
101                        return PluginResult::deny(PluginViolation::new(
102                            "insufficient_role",
103                            format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name),
104                        ));
105                    }
106                }
107            }
108
109            if extensions.http.is_some() {
110                println!("  [identity-checker] WARNING: HTTP visible (unexpected!)");
111            } else {
112                println!("  [identity-checker] HTTP: not visible (correct — no read_headers)");
113            }
114            println!("  [identity-checker] PRE-INVOKE ALLOWED");
115        }
116
117        PluginResult::allow()
118    }

Trait Implementations§

Source§

impl Clone for SecurityExtension

Source§

fn clone(&self) -> SecurityExtension

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SecurityExtension

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for SecurityExtension

Source§

fn default() -> SecurityExtension

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for SecurityExtension

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Serialize for SecurityExtension

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more