Skip to main content

oxicode_sdk/middleware/
observability_adapters.rs

1//! Observability adapters — bridges for SDK observability types into the
2//! [`Middleware`] pipeline so they cooperate with user-added middleware
3//! instead of overwriting it.
4//!
5//! Background: [`crate::agent_builder::AgentBuilder`] stores `AuditLog`,
6//! `Authorizer`, `Tracer`, and `CostTracker` setters that used to be
7//! silently dropped. The audit report `docs/audits/2026-06-30-sdk-coverage.md`
8//! flagged this as **API theater**. The fix is split into two layers:
9//!
10//! 1. The hook-slot observations (AuditLog, Authorizer) use the
11//!    [`Middleware`] trait — they push into the SAME pipeline that
12//!    `build_hooks()` consumes in
13//!    `crate::agent_builder::AgentBuilder::build`. This way user-added
14//!    middlewares (rate limit, logging, ...) and audit/auth all fire
15//!    through the same unified `BeforeTool` / `AfterTool` chain.
16//!    `set_hooks()` (which REPLACES the whole `AgentHooks`) is still
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use crate::middleware::{
22    Middleware, MiddlewareContext, MiddlewareData, MiddlewarePhase, MiddlewareResult,
23};
24use crate::observability::{AuditEntry, AuditLog};
25use crate::security::Authorizer;
26
27/// Audit-log middleware — emits an `AuditEntry::lifecycle` (tool_start)
28/// on `BeforeTool` and an `AuditEntry::tool_execution` on `AfterTool`,
29/// plus records `SecurityDecision` entries supplied by [`AuthorizerMiddleware`]
30/// when wrapped together via [`crate::middleware::build_hooks`] chain.
31pub struct AuditLogMiddleware {
32    audit: Arc<AuditLog>,
33    agent_id: String,
34}
35
36impl AuditLogMiddleware {
37    /// Create a new audit-log middleware bound to a specific agent id.
38    pub fn new(audit: Arc<AuditLog>, agent_id: impl Into<String>) -> Self {
39        Self {
40            audit,
41            agent_id: agent_id.into(),
42        }
43    }
44
45    fn handle_before(&self, ctx: &MiddlewareContext) -> MiddlewareResult {
46        let tool_name = match &ctx.data {
47            MiddlewareData::BeforeTool { tool_name, .. } => tool_name.clone(),
48            _ => return MiddlewareResult::pass(),
49        };
50        self.audit.log(AuditEntry::lifecycle(
51            self.agent_id.clone(),
52            format!("tool_start:{}", tool_name),
53        ));
54        MiddlewareResult::pass()
55    }
56
57    fn handle_after(&self, ctx: &MiddlewareContext) -> MiddlewareResult {
58        let tool_name = match &ctx.data {
59            MiddlewareData::AfterTool {
60                tool_name, result, ..
61            } => {
62                let success = !result.is_empty() && !result.starts_with("error:");
63                (tool_name.clone(), success)
64            }
65            _ => return MiddlewareResult::pass(),
66        };
67        let (name, success) = tool_name;
68        self.audit.log(AuditEntry::tool_execution(
69            self.agent_id.clone(),
70            name,
71            "{}".into(),
72            success,
73            0,
74        ));
75        MiddlewareResult::pass()
76    }
77}
78
79impl Middleware for AuditLogMiddleware {
80    fn name(&self) -> &str {
81        "AuditLogMiddleware"
82    }
83
84    fn phases(&self) -> Vec<MiddlewarePhase> {
85        vec![MiddlewarePhase::BeforeTool, MiddlewarePhase::AfterTool]
86    }
87
88    fn handle<'a>(
89        &'a self,
90        ctx: &'a MiddlewareContext,
91    ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
92        let result = match ctx.phase {
93            MiddlewarePhase::BeforeTool => self.handle_before(ctx),
94            MiddlewarePhase::AfterTool => self.handle_after(ctx),
95            _ => MiddlewareResult::pass(),
96        };
97        Box::pin(async move { result })
98    }
99}
100
101/// Authorizer middleware — denies a tool call when the agent lacks the
102/// required capability. Uses `crate::security::Authorizer::check`.
103pub struct AuthorizerMiddleware {
104    authorizer: Arc<Authorizer>,
105    audit: Option<Arc<AuditLog>>,
106    agent_id: String,
107}
108
109impl AuthorizerMiddleware {
110    /// Create a new authorizer middleware bound to a specific agent id.
111    pub fn new(authorizer: Arc<Authorizer>, agent_id: impl Into<String>) -> Self {
112        Self {
113            authorizer,
114            audit: None,
115            agent_id: agent_id.into(),
116        }
117    }
118
119    /// Attach an audit log so denials are recorded as `SecurityDecision`
120    /// entries. Without this, denials are silent on the audit trail.
121    pub fn with_audit(mut self, audit: Arc<AuditLog>) -> Self {
122        self.audit = Some(audit);
123        self
124    }
125}
126
127impl Middleware for AuthorizerMiddleware {
128    fn name(&self) -> &str {
129        "AuthorizerMiddleware"
130    }
131
132    fn phases(&self) -> Vec<MiddlewarePhase> {
133        vec![MiddlewarePhase::BeforeTool]
134    }
135    fn handle<'a>(
136        &'a self,
137        ctx: &'a MiddlewareContext,
138    ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
139        let tool_name = match &ctx.data {
140            MiddlewareData::BeforeTool { tool_name, .. } => tool_name.clone(),
141            _ => {
142                return Box::pin(async move { MiddlewareResult::pass() });
143            }
144        };
145
146        let authorizer = Arc::clone(&self.authorizer);
147        let audit = self.audit.clone();
148        let agent_id = self.agent_id.clone();
149        let cap = crate::security::Capability::ToolUse {
150            tool_name: tool_name.clone(),
151        };
152
153        Box::pin(async move {
154            let subject = crate::security::CapabilitySubject::Agent(agent_id.clone());
155            let granted = authorizer.check(&subject, &cap);
156            if let Some(audit) = audit {
157                audit.log(AuditEntry::security_decision(
158                    agent_id,
159                    format!("tool:{}", tool_name),
160                    granted,
161                ));
162            }
163            if granted {
164                MiddlewareResult::pass()
165            } else {
166                MiddlewareResult::block(format!(
167                    "authorizer denied tool `{}` for agent `{}`",
168                    tool_name, subject
169                ))
170            }
171        })
172    }
173}
174
175/// Helper used by `AgentBuilder::build()`: synthesize an agent id for
176/// observability dispatch if `AgentConfig::name` is empty, matching the
177/// existing pattern at agent_builder.rs:443-447.
178pub fn resolved_agent_id(config: &oxicode_agent::AgentConfig) -> String {
179    if config.name.is_empty() {
180        uuid::Uuid::new_v4().to_string()
181    } else {
182        config.name.clone()
183    }
184}