Skip to content

Commit 9d08049

Browse files
committed
feat: added invoke named support, added constants, fixed reviewed code.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent e048311 commit 9d08049

8 files changed

Lines changed: 598 additions & 158 deletions

File tree

crates/cpex-core/examples/cmf_capabilities_demo.rs

Lines changed: 150 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -48,44 +48,56 @@ impl HookHandler<CmfHook> for IdentityChecker {
4848
extensions: &Extensions,
4949
_ctx: &mut PluginContext,
5050
) -> PluginResult<MessagePayload> {
51-
let tool_name = payload.message.get_tool_calls()
52-
.first()
53-
.map(|tc| tc.name.as_str())
54-
.unwrap_or("unknown");
55-
56-
// Check security labels (capability: read_labels)
57-
if let Some(ref security) = extensions.security {
58-
let labels: Vec<&String> = security.labels.iter().collect();
59-
println!(" [identity-checker] Security labels visible: {:?}", labels);
60-
println!(" [identity-checker] Classification: {:?}", security.classification);
61-
62-
// Check subject (capability: read_subject, read_roles)
63-
if let Some(ref subject) = security.subject {
64-
println!(" [identity-checker] Subject: {:?}", subject.id);
65-
let roles: Vec<&String> = subject.roles.iter().collect();
66-
println!(" [identity-checker] Roles: {:?}", roles);
67-
68-
if security.has_label("PII") && !subject.roles.contains("hr_admin") {
69-
return PluginResult::deny(PluginViolation::new(
70-
"insufficient_role",
71-
format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name),
72-
));
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.message.get_tool_results()
57+
.first()
58+
.map(|tr| tr.tool_name.as_str())
59+
.unwrap_or("unknown");
60+
println!(" [identity-checker] POST-INVOKE: verifying result from '{}'", tool_name);
61+
62+
if let Some(ref security) = extensions.security {
63+
if let Some(ref subject) = security.subject {
64+
println!(" [identity-checker] Result authorized for subject: {:?}", subject.id);
7365
}
74-
} else {
75-
println!(" [identity-checker] No subject visible (missing capability)");
7666
}
67+
println!(" [identity-checker] POST-INVOKE ALLOWED");
7768
} else {
78-
println!(" [identity-checker] No security extension visible");
79-
}
69+
// PRE-INVOKE: check caller identity and roles
70+
let tool_name = payload.message.get_tool_calls()
71+
.first()
72+
.map(|tc| tc.name.as_str())
73+
.unwrap_or("unknown");
74+
println!(" [identity-checker] PRE-INVOKE: checking identity for '{}'", tool_name);
75+
76+
if let Some(ref security) = extensions.security {
77+
let labels: Vec<&String> = security.labels.iter().collect();
78+
println!(" [identity-checker] Security labels: {:?}", labels);
79+
80+
if let Some(ref subject) = security.subject {
81+
println!(" [identity-checker] Subject: {:?}, Roles: {:?}",
82+
subject.id, subject.roles.iter().collect::<Vec<_>>());
83+
84+
if security.has_label("PII") && !subject.roles.contains("hr_admin") {
85+
return PluginResult::deny(PluginViolation::new(
86+
"insufficient_role",
87+
format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name),
88+
));
89+
}
90+
}
91+
}
8092

81-
// Check HTTP (should NOT be visible — no read_headers capability)
82-
if extensions.http.is_some() {
83-
println!(" [identity-checker] WARNING: HTTP visible (unexpected!)");
84-
} else {
85-
println!(" [identity-checker] HTTP: not visible (no read_headers capability)");
93+
if extensions.http.is_some() {
94+
println!(" [identity-checker] WARNING: HTTP visible (unexpected!)");
95+
} else {
96+
println!(" [identity-checker] HTTP: not visible (correct — no read_headers)");
97+
}
98+
println!(" [identity-checker] PRE-INVOKE ALLOWED");
8699
}
87100

88-
println!(" [identity-checker] ALLOWED: tool '{}' for authorized user", tool_name);
89101
PluginResult::allow()
90102
}
91103
}
@@ -114,7 +126,7 @@ impl HookHandler<CmfHook> for HeaderInjector {
114126
) -> PluginResult<MessagePayload> {
115127
// Can see HTTP (has read_headers)
116128
if let Some(ref http) = extensions.http {
117-
println!(" [header-injector] HTTP headers visible: {:?}", http.read().headers);
129+
println!(" [header-injector] HTTP headers visible: {:?}", http.read().request_headers);
118130
}
119131

120132
// Can NOT see security subject (no read_subject)
@@ -167,12 +179,22 @@ impl HookHandler<CmfHook> for AuditLogger {
167179
extensions: &Extensions,
168180
_ctx: &mut PluginContext,
169181
) -> PluginResult<MessagePayload> {
170-
let tool_name = payload.message.get_tool_calls()
171-
.first()
172-
.map(|tc| tc.name.as_str())
173-
.unwrap_or("unknown");
182+
let is_result = payload.message.is_tool_result();
183+
let phase = if is_result { "POST" } else { "PRE" };
184+
185+
let tool_name = if is_result {
186+
payload.message.get_tool_results()
187+
.first()
188+
.map(|tr| tr.tool_name.as_str())
189+
.unwrap_or("unknown")
190+
} else {
191+
payload.message.get_tool_calls()
192+
.first()
193+
.map(|tc| tc.name.as_str())
194+
.unwrap_or("unknown")
195+
};
174196

175-
print!(" [audit-logger] AUDIT: tool='{}' ", tool_name);
197+
print!(" [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name);
176198

177199
if let Some(ref security) = extensions.security {
178200
let labels: Vec<&String> = security.labels.iter().collect();
@@ -185,8 +207,12 @@ impl HookHandler<CmfHook> for AuditLogger {
185207
}
186208
}
187209

188-
if let Some(ref meta) = extensions.meta {
189-
print!("entity='{:?}' ", meta.entity_name);
210+
if is_result {
211+
let is_error = payload.message.get_tool_results()
212+
.first()
213+
.map(|tr| tr.is_error)
214+
.unwrap_or(false);
215+
print!("error={} ", is_error);
190216
}
191217

192218
println!();
@@ -205,7 +231,8 @@ impl PluginFactory for IdentityCheckerFactory {
205231
Ok(PluginInstance {
206232
plugin: plugin.clone(),
207233
handlers: vec![
208-
("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin))),
234+
("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin.clone()))),
235+
("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin))),
209236
],
210237
})
211238
}
@@ -231,7 +258,8 @@ impl PluginFactory for AuditLoggerFactory {
231258
Ok(PluginInstance {
232259
plugin: plugin.clone(),
233260
handlers: vec![
234-
("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin))),
261+
("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin.clone()))),
262+
("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin))),
235263
],
236264
})
237265
}
@@ -262,7 +290,7 @@ async fn main() {
262290
// --- Build CMF Message ---
263291
let payload = MessagePayload {
264292
message: Message {
265-
schema_version: "2.0".into(),
293+
schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
266294
role: Role::Assistant,
267295
content: vec![
268296
ContentPart::Text { text: "Looking up compensation.".into() },
@@ -313,27 +341,95 @@ async fn main() {
313341
..Default::default()
314342
};
315343

316-
// --- Invoke ---
317-
println!("--- Invoking cmf.tool_pre_invoke ---\n");
318-
let boxed: Box<dyn cpex_core::hooks::PluginPayload> = Box::new(payload);
319-
let (result, bg) = mgr.invoke_by_name("cmf.tool_pre_invoke", boxed, ext, None).await;
344+
// --- Pre-invoke: type-safe dispatch via invoke_named ---
345+
println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
346+
347+
// invoke_named<CmfHook> gives compile-time payload type checking
348+
// while routing to the specific "cmf.tool_pre_invoke" hook name
349+
let (pre_result, bg) = mgr.invoke_named::<CmfHook>(
350+
"cmf.tool_pre_invoke",
351+
payload,
352+
ext,
353+
None, // first hook — no context table
354+
).await;
320355

321356
println!();
322-
if result.continue_processing {
323-
println!("Result: ALLOWED");
324-
if let Some(ref modified_ext) = result.modified_extensions {
357+
if pre_result.continue_processing {
358+
println!("Pre-invoke result: ALLOWED");
359+
if let Some(ref modified_ext) = pre_result.modified_extensions {
325360
if let Some(ref sec) = modified_ext.security {
326361
let labels: Vec<&String> = sec.labels.iter().collect();
327-
println!("Final labels: {:?}", labels);
362+
println!(" Labels after pre-invoke: {:?}", labels);
328363
}
329364
if let Some(ref http) = modified_ext.http {
330-
println!("Final headers: {:?}", http.read().headers);
365+
println!(" Headers after pre-invoke: {:?}", http.read().request_headers);
331366
}
332367
}
333368
} else {
334-
println!("Result: DENIED — {}", result.violation.as_ref().unwrap().reason);
369+
println!("Pre-invoke result: DENIED — {}", pre_result.violation.as_ref().unwrap().reason);
370+
bg.wait_for_background_tasks().await;
371+
println!("\n=== Demo complete ===");
372+
return;
335373
}
336-
337374
bg.wait_for_background_tasks().await;
375+
376+
// --- Simulate tool execution ---
377+
println!("\n--- Tool 'get_compensation' executes... ---");
378+
println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
379+
380+
// --- Post-invoke: different CMF message with tool result ---
381+
println!("=== Phase 2: cmf.tool_post_invoke ===\n");
382+
383+
let post_payload = MessagePayload {
384+
message: Message {
385+
schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
386+
role: Role::Tool,
387+
content: vec![
388+
ContentPart::ToolResult {
389+
content: cpex_core::cmf::ToolResult {
390+
tool_call_id: "tc_001".into(),
391+
tool_name: "get_compensation".into(),
392+
content: serde_json::json!({"salary": 150000, "currency": "USD"}),
393+
is_error: false,
394+
},
395+
},
396+
],
397+
channel: None,
398+
},
399+
};
400+
401+
// Build post-invoke extensions — carry forward any modifications
402+
// from pre-invoke via the context table
403+
let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
404+
// Rebuild if no modifications
405+
let mut security = SecurityExtension::default();
406+
security.add_label("PII");
407+
Extensions {
408+
security: Some(security),
409+
meta: Some(Arc::new(MetaExtension {
410+
entity_type: Some("tool".into()),
411+
entity_name: Some("get_compensation".into()),
412+
..Default::default()
413+
})),
414+
..Default::default()
415+
}
416+
});
417+
418+
// Thread the context table from pre-invoke to preserve plugin state
419+
let (post_result, post_bg) = mgr.invoke_named::<CmfHook>(
420+
"cmf.tool_post_invoke",
421+
post_payload,
422+
post_ext,
423+
Some(pre_result.context_table),
424+
).await;
425+
426+
println!();
427+
if post_result.continue_processing {
428+
println!("Post-invoke result: ALLOWED");
429+
} else {
430+
println!("Post-invoke result: DENIED — {}", post_result.violation.as_ref().unwrap().reason);
431+
}
432+
433+
post_bg.wait_for_background_tasks().await;
338434
println!("\n=== Demo complete ===");
339435
}

crates/cpex-core/examples/cmf_capabilities_demo.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# CMF Capabilities Demo Configuration
22
#
33
# Three plugins with different capabilities see different views
4-
# of the same extensions. Demonstrates capability-gated access.
4+
# of the same extensions. Demonstrates capability-gated access
5+
# across pre-invoke and post-invoke hooks.
56

67
plugin_settings:
78
routing_enabled: true
@@ -14,7 +15,7 @@ global:
1415
plugins:
1516
- name: identity-checker
1617
kind: builtin/identity-checker
17-
hooks: [cmf.tool_pre_invoke]
18+
hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke]
1819
mode: sequential
1920
priority: 10
2021
on_error: fail
@@ -36,7 +37,7 @@ plugins:
3637

3738
- name: audit-logger
3839
kind: builtin/audit-logger
39-
hooks: [cmf.tool_pre_invoke]
40+
hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke]
4041
mode: audit
4142
priority: 100
4243
on_error: ignore
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Location: ./crates/cpex-core/src/cmf/constants.rs
2+
// Copyright 2025
3+
// SPDX-License-Identifier: Apache-2.0
4+
// Authors: Teryl Taylor
5+
//
6+
// CMF constants — schema version, serialization field names, and defaults.
7+
8+
/// Current CMF message schema version.
9+
pub const SCHEMA_VERSION: &str = "2.0";
10+
11+
// ---------------------------------------------------------------------------
12+
// Serialization field names for MessageView::to_dict() / to_opa_input()
13+
// ---------------------------------------------------------------------------
14+
15+
// Core view fields
16+
pub const FIELD_KIND: &str = "kind";
17+
pub const FIELD_ROLE: &str = "role";
18+
pub const FIELD_IS_PRE: &str = "is_pre";
19+
pub const FIELD_IS_POST: &str = "is_post";
20+
pub const FIELD_ACTION: &str = "action";
21+
pub const FIELD_HOOK: &str = "hook";
22+
pub const FIELD_URI: &str = "uri";
23+
pub const FIELD_NAME: &str = "name";
24+
pub const FIELD_CONTENT: &str = "content";
25+
pub const FIELD_SIZE_BYTES: &str = "size_bytes";
26+
pub const FIELD_MIME_TYPE: &str = "mime_type";
27+
pub const FIELD_ARGUMENTS: &str = "arguments";
28+
29+
// Extensions container
30+
pub const FIELD_EXTENSIONS: &str = "extensions";
31+
32+
// Subject fields
33+
pub const FIELD_SUBJECT: &str = "subject";
34+
pub const FIELD_ID: &str = "id";
35+
pub const FIELD_TYPE: &str = "type";
36+
pub const FIELD_ROLES: &str = "roles";
37+
pub const FIELD_PERMISSIONS: &str = "permissions";
38+
pub const FIELD_TEAMS: &str = "teams";
39+
40+
// Security fields
41+
pub const FIELD_LABELS: &str = "labels";
42+
43+
// Request fields
44+
pub const FIELD_ENVIRONMENT: &str = "environment";
45+
46+
// HTTP fields
47+
pub const FIELD_HEADERS: &str = "headers";
48+
49+
// Agent fields
50+
pub const FIELD_AGENT: &str = "agent";
51+
pub const FIELD_INPUT: &str = "input";
52+
pub const FIELD_SESSION_ID: &str = "session_id";
53+
pub const FIELD_CONVERSATION_ID: &str = "conversation_id";
54+
pub const FIELD_TURN: &str = "turn";
55+
pub const FIELD_AGENT_ID: &str = "agent_id";
56+
pub const FIELD_PARENT_AGENT_ID: &str = "parent_agent_id";
57+
58+
// Meta fields
59+
pub const FIELD_META: &str = "meta";
60+
pub const FIELD_ENTITY_TYPE: &str = "entity_type";
61+
pub const FIELD_ENTITY_NAME: &str = "entity_name";
62+
pub const FIELD_TAGS: &str = "tags";
63+
64+
// OPA envelope
65+
pub const FIELD_OPA_INPUT: &str = "input";

crates/cpex-core/src/cmf/message.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ pub struct Message {
5252
}
5353

5454
fn default_schema_version() -> String {
55-
"2.0".to_string()
55+
super::constants::SCHEMA_VERSION.to_string()
5656
}
5757

5858
impl Message {
5959
/// Create a simple text message.
6060
pub fn text(role: Role, text: impl Into<String>) -> Self {
6161
Self {
62-
schema_version: "2.0".to_string(),
62+
schema_version: super::constants::SCHEMA_VERSION.to_string(),
6363
role,
6464
content: vec![ContentPart::Text {
6565
text: text.into(),

0 commit comments

Comments
 (0)