pub struct HttpExtension {
pub request_headers: HashMap<String, String>,
pub response_headers: HashMap<String, String>,
pub method: Option<String>,
pub path: Option<String>,
pub host: Option<String>,
pub scheme: Option<String>,
}Expand description
HTTP-related extensions.
Carries both request and response headers separately. The host populates what’s available at each hook point:
- Pre-invoke:
request_headersfilled,response_headersempty - Post-invoke: both filled (request from original, response from upstream)
Capability-gated: requires read_headers to see, write_headers
to modify (both request and response).
Fields§
§request_headers: HashMap<String, String>HTTP request headers (inbound from caller).
response_headers: HashMap<String, String>HTTP response headers (from upstream, populated post-invoke).
method: Option<String>HTTP request method (e.g. GET, POST). Set by the host when
the request is HTTP; None for non-HTTP transports.
path: Option<String>HTTP request path (e.g. /api/v1/widgets). Excludes the query
string unless the host chooses to include it.
host: Option<String>HTTP request authority/host. The host MUST populate this from a
validated authority (e.g. the HTTP/2 :authority pseudo-header),
never a raw client-supplied Host header, so host-based policy
is not bypassable.
scheme: Option<String>HTTP request scheme (http / https).
Implementations§
Source§impl HttpExtension
impl HttpExtension
Sourcepub fn set_request_header(
&mut self,
name: impl Into<String>,
value: impl Into<String>,
)
pub fn set_request_header( &mut self, name: impl Into<String>, value: impl Into<String>, )
Set a request header (overwrites if exists).
Sourcepub fn get_request_header(&self, name: &str) -> Option<&str>
pub fn get_request_header(&self, name: &str) -> Option<&str>
Get a request header value (case-insensitive lookup).
Sourcepub fn has_request_header(&self, name: &str) -> bool
pub fn has_request_header(&self, name: &str) -> bool
Check if a request header exists (case-insensitive).
Sourcepub fn add_request_header(
&mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> bool
pub fn add_request_header( &mut self, name: impl Into<String>, value: impl Into<String>, ) -> bool
Add request header only if it doesn’t exist. Returns true if added.
Sourcepub fn remove_request_header(&mut self, name: &str) -> Option<String>
pub fn remove_request_header(&mut self, name: &str) -> Option<String>
Remove a request header by name. Returns the removed value.
Sourcepub fn set_response_header(
&mut self,
name: impl Into<String>,
value: impl Into<String>,
)
pub fn set_response_header( &mut self, name: impl Into<String>, value: impl Into<String>, )
Set a response header (overwrites if exists).
Sourcepub fn get_response_header(&self, name: &str) -> Option<&str>
pub fn get_response_header(&self, name: &str) -> Option<&str>
Get a response header value (case-insensitive lookup).
Sourcepub fn has_response_header(&self, name: &str) -> bool
pub fn has_response_header(&self, name: &str) -> bool
Check if a response header exists (case-insensitive).
Sourcepub fn set_header(&mut self, name: impl Into<String>, value: impl Into<String>)
pub fn set_header(&mut self, name: impl Into<String>, value: impl Into<String>)
Set a header on request headers (convenience alias).
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 get_header(&self, name: &str) -> Option<&str>
pub fn get_header(&self, name: &str) -> Option<&str>
Get a header from request headers (convenience alias, case-insensitive).
Examples found in repository?
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 }Sourcepub fn has_header(&self, name: &str) -> bool
pub fn has_header(&self, name: &str) -> bool
Check if a request header exists (convenience alias).
Trait Implementations§
Source§impl Clone for HttpExtension
impl Clone for HttpExtension
Source§fn clone(&self) -> HttpExtension
fn clone(&self) -> HttpExtension
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more