Skip to main content

ferrin_policy/
approval.rs

1//! Approval policies backed by a policy client.
2
3use std::fmt;
4use std::sync::Arc;
5
6use ferrin_core::generate_text::ApprovalContext;
7use ferrin_core::generate_text::ApprovalPolicy;
8use ferrin_core::generate_text::ApprovalStatus;
9use ferrin_core::generate_text::ParsedToolCall;
10use ferrin_spec::BoxFuture;
11use ferrin_spec::JsonValue;
12use serde_json::json;
13
14use crate::client::PolicyClient;
15use crate::decision::PolicyDecision;
16
17/// What an approval policy or capability middleware does when the policy
18/// cannot be evaluated (transport failure, engine error, invalid response).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum FailureMode {
22    /// Fail closed: deny the call (or clear the tools).
23    #[default]
24    Deny,
25    /// Fail open: fall through to the tool's own `needs_approval` (or keep
26    /// the tools unchanged).
27    FallThrough,
28}
29
30/// Builds the policy input for a tool call.
31pub type ToInputFn = Arc<dyn Fn(&ParsedToolCall, &ApprovalContext<'_>) -> JsonValue + Send + Sync>;
32
33/// The default policy input matches the reference SDK's OPA rule input.
34///
35/// The document contains `tool: { name }`, `args`, `messages` and
36/// `runtimeContext`. Use [`PolicyApproval::to_input`] for a custom contract.
37#[must_use]
38pub fn default_input(call: &ParsedToolCall, ctx: &ApprovalContext<'_>) -> JsonValue {
39    json!({
40        "tool": { "name": call.tool_name },
41        "args": call.input,
42        "messages": serde_json::to_value(ctx.messages).unwrap_or(JsonValue::Null),
43        "runtimeContext": ctx.runtime_context.cloned().unwrap_or(JsonValue::Null),
44    })
45}
46
47/// Approval policy created by [`policy_approval`].
48pub struct PolicyApproval<C> {
49    client: C,
50    path: String,
51    to_input: Option<ToInputFn>,
52    on_error: FailureMode,
53}
54
55/// Resolves tool approvals by evaluating the policy at `path` with `client`.
56///
57/// The input is [`default_input`] unless [`PolicyApproval::to_input`]
58/// replaces it; the result is normalized with [`PolicyDecision::normalize`]
59/// and mapped with [`PolicyDecision::into_approval`]. Evaluation errors deny
60/// the call with the reason `policy evaluation failed` unless
61/// [`PolicyApproval::on_error`] selects [`FailureMode::FallThrough`].
62pub fn policy_approval<C: PolicyClient>(client: C, path: impl Into<String>) -> PolicyApproval<C> {
63    PolicyApproval {
64        client,
65        path: path.into(),
66        to_input: None,
67        on_error: FailureMode::Deny,
68    }
69}
70
71impl<C> PolicyApproval<C> {
72    /// Replaces the default input document.
73    #[must_use]
74    pub fn to_input(
75        mut self,
76        f: impl Fn(&ParsedToolCall, &ApprovalContext<'_>) -> JsonValue + Send + Sync + 'static,
77    ) -> Self {
78        self.to_input = Some(Arc::new(f));
79        self
80    }
81
82    /// Sets the behaviour on evaluation errors (default: deny).
83    #[must_use]
84    pub fn on_error(mut self, mode: FailureMode) -> Self {
85        self.on_error = mode;
86        self
87    }
88
89    /// The policy path.
90    #[must_use]
91    pub fn path(&self) -> &str {
92        &self.path
93    }
94
95    /// The client.
96    #[must_use]
97    pub fn client(&self) -> &C {
98        &self.client
99    }
100}
101
102impl<C> fmt::Debug for PolicyApproval<C> {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.debug_struct("PolicyApproval")
105            .field("path", &self.path)
106            .field("custom_input", &self.to_input.is_some())
107            .field("on_error", &self.on_error)
108            .finish_non_exhaustive()
109    }
110}
111
112impl<C: PolicyClient> ApprovalPolicy for PolicyApproval<C> {
113    fn resolve<'a>(
114        &'a self,
115        call: &'a ParsedToolCall,
116        ctx: ApprovalContext<'a>,
117    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
118        Box::pin(async move {
119            let input = match &self.to_input {
120                Some(to_input) => to_input(call, &ctx),
121                None => default_input(call, &ctx),
122            };
123            match self.client.evaluate(&self.path, input).await {
124                Ok(raw) => {
125                    let decision = PolicyDecision::normalize(&raw);
126                    tracing::debug!(
127                        tool = %call.tool_name,
128                        path = %self.path,
129                        decision = crate::diagnostics::decision_kind(&decision),
130                        "policy decision"
131                    );
132                    decision.into_approval()
133                }
134                Err(_error) => {
135                    tracing::warn!(
136                        tool = %call.tool_name,
137                        path = %self.path,
138                        "policy evaluation failed"
139                    );
140                    match self.on_error {
141                        FailureMode::Deny => Some(ApprovalStatus::Denied {
142                            reason: Some("policy evaluation failed".to_owned()),
143                        }),
144                        FailureMode::FallThrough => None,
145                    }
146                }
147            }
148        })
149    }
150}
151
152/// Approval policy created by [`with_default`].
153pub struct WithDefault<P> {
154    inner: P,
155    default: ApprovalStatus,
156}
157
158/// Gives calls with `None` or `NotApplicable` from the inner policy the status
159/// `default`, so that every call has a decision.
160///
161/// Typical use: tools bridged from an MCP server have no `needs_approval`
162/// declaration; `with_default(policy, ApprovalStatus::user_approval())` makes
163/// unmatched calls wait for a human instead of executing.
164pub fn with_default<P: ApprovalPolicy>(policy: P, default: ApprovalStatus) -> WithDefault<P> {
165    WithDefault {
166        inner: policy,
167        default,
168    }
169}
170
171impl<P> fmt::Debug for WithDefault<P> {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("WithDefault")
174            .field(
175                "default",
176                &crate::diagnostics::status_kind(Some(&self.default)),
177            )
178            .finish_non_exhaustive()
179    }
180}
181
182impl<P: ApprovalPolicy> ApprovalPolicy for WithDefault<P> {
183    fn resolve<'a>(
184        &'a self,
185        call: &'a ParsedToolCall,
186        ctx: ApprovalContext<'a>,
187    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
188        Box::pin(async move {
189            match self.inner.resolve(call, ctx).await {
190                Some(ApprovalStatus::NotApplicable) | None => Some(self.default.clone()),
191                Some(status) => Some(status),
192            }
193        })
194    }
195}