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 requestclient— the OAuth client / application brokering the requestcaller_workload— the attested workload on the inbound network peer (SPIFFE-SVID, mTLS cert chain)this_workload— our 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
impl SecurityExtension
Sourcepub fn add_label(&mut self, label: impl Into<String>)
pub fn add_label(&mut self, label: impl Into<String>)
Add a security label (monotonic — cannot remove).
Examples found in repository?
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}Sourcepub fn has_label(&self, label: &str) -> bool
pub fn has_label(&self, label: &str) -> bool
Check if a label exists (case-insensitive).
Examples found in repository?
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
impl Clone for SecurityExtension
Source§fn clone(&self) -> SecurityExtension
fn clone(&self) -> SecurityExtension
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more