Skip to main content

llm_kernel/provider/
policy.rs

1//! Provider data policy — what content may leave the machine for a provider.
2//!
3//! [`DataPolicy`] is a field of
4//! [`ServiceDescriptor`](crate::provider::ServiceDescriptor), so this
5//! vocabulary lives in `provider` and stays free of engine concerns.
6//!
7//! ```
8//! use llm_kernel::provider::{DataPolicy, ProviderIndex, Sensitivity};
9//!
10//! let catalog = ProviderIndex::embedded();
11//! let policy = catalog
12//!     .get("openai")
13//!     .map(DataPolicy::default_for)
14//!     .unwrap_or_default();
15//! // `Sensitivity` is ordered: Public < Internal < Confidential < Restricted.
16//! assert!(Sensitivity::Restricted > Sensitivity::Public);
17//! assert_eq!(policy.image, llm_kernel::provider::ImagePolicy::Allow);
18//! ```
19
20use serde::{Deserialize, Serialize};
21
22/// Overall sensitivity grade of scanned content.
23///
24/// Variant order is the ordering (`Public < Internal < Confidential <
25/// Restricted`) — policy thresholds compare with it.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Sensitivity {
30    /// No findings.
31    #[default]
32    Public,
33    /// Low/medium findings only (filesystem paths, phone numbers).
34    Internal,
35    /// High-severity findings (bank accounts, generic credential assignments).
36    Confidential,
37    /// Critical findings (credentials, RRN) — must not leave unredacted.
38    Restricted,
39}
40
41/// Action to take on content bound for a provider.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum PolicyAction {
46    /// Send as-is.
47    Allow,
48    /// Replace flagged spans (the scan's `redact_spans`), then send.
49    Redact,
50    /// Send, but surface a warning to the user.
51    Warn,
52    /// Send to a different provider instead.
53    ReRoute {
54        /// Target provider id (e.g. `"ollama"`).
55        provider: String,
56    },
57    /// Do not send.
58    Block,
59}
60
61/// Whether binary (image) payloads may be sent to the provider.
62#[non_exhaustive]
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ImagePolicy {
66    /// Images may be sent (compatibility first; strip via explicit policy).
67    #[default]
68    Allow,
69    /// Images are stripped before the request leaves the machine.
70    Strip,
71}
72
73/// One step of the sensitivity-to-action ladder.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct PolicyThreshold {
76    /// Minimum sensitivity at which `action` applies.
77    pub min_sensitivity: Sensitivity,
78    /// Action taken when content sensitivity is at or above the threshold.
79    pub action: PolicyAction,
80}
81
82/// Per-provider data-loss-prevention policy, carried on
83/// [`ServiceDescriptor`](crate::provider::ServiceDescriptor).
84///
85/// Catalog entries ship `None` in the first cut; [`DataPolicy::default_for`]
86/// supplies code-level defaults.
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
89pub struct DataPolicy {
90    /// Whether binary (image) payloads are allowed.
91    #[serde(default)]
92    pub image: ImagePolicy,
93    /// Sensitivity-thresholded actions. `lookup` picks the highest threshold
94    /// satisfied by the content's sensitivity; `Allow` when none match.
95    /// `Default` (empty) is the permissive policy.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub thresholds: Vec<PolicyThreshold>,
98}
99
100impl DataPolicy {
101    /// Code-level default policy for a provider.
102    ///
103    /// Local providers (`family == "local"`: ollama, lmstudio, llamacpp) get
104    /// the permissive policy — traffic never leaves the machine. Everything
105    /// else gets: Restricted → Block, Confidential → ReRoute to `"ollama"`,
106    /// Internal → Warn; images default to
107    /// [`Allow`](ImagePolicy::Allow) for vision-workflow compatibility.
108    pub fn default_for(svc: &crate::provider::ServiceDescriptor) -> Self {
109        if svc.family == "local" {
110            return Self::default();
111        }
112        Self {
113            image: ImagePolicy::Allow,
114            thresholds: vec![
115                PolicyThreshold {
116                    min_sensitivity: Sensitivity::Restricted,
117                    action: PolicyAction::Block,
118                },
119                PolicyThreshold {
120                    min_sensitivity: Sensitivity::Confidential,
121                    action: PolicyAction::ReRoute {
122                        provider: "ollama".to_string(),
123                    },
124                },
125                PolicyThreshold {
126                    min_sensitivity: Sensitivity::Internal,
127                    action: PolicyAction::Warn,
128                },
129            ],
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn default_is_permissive() {
140        let policy = DataPolicy::default();
141        assert!(policy.thresholds.is_empty());
142        assert_eq!(policy.image, ImagePolicy::Allow);
143    }
144
145    #[test]
146    fn sensitivity_is_ordered() {
147        assert!(Sensitivity::Public < Sensitivity::Internal);
148        assert!(Sensitivity::Internal < Sensitivity::Confidential);
149        assert!(Sensitivity::Confidential < Sensitivity::Restricted);
150    }
151
152    #[test]
153    fn data_policy_serde_roundtrip_keeps_reroute_payload() {
154        let policy = DataPolicy {
155            image: ImagePolicy::Strip,
156            thresholds: vec![PolicyThreshold {
157                min_sensitivity: Sensitivity::Confidential,
158                action: PolicyAction::ReRoute {
159                    provider: "ollama".to_string(),
160                },
161            }],
162        };
163        let json = serde_json::to_string(&policy).expect("serialize");
164        let back: DataPolicy = serde_json::from_str(&json).expect("deserialize");
165        assert_eq!(back, policy);
166        assert!(json.contains("\"re_route\""), "got: {json}");
167    }
168
169    #[test]
170    fn default_for_local_provider_is_permissive() {
171        let catalog = crate::provider::ProviderIndex::embedded();
172        let ollama = catalog.get("ollama").expect("ollama in catalog");
173        assert_eq!(ollama.family, "local");
174        assert!(DataPolicy::default_for(ollama).thresholds.is_empty());
175    }
176
177    #[test]
178    fn default_for_cloud_provider_has_thresholds() {
179        let catalog = crate::provider::ProviderIndex::embedded();
180        let openai = catalog.get("openai").expect("openai in catalog");
181        let policy = DataPolicy::default_for(openai);
182        assert_eq!(policy.image, ImagePolicy::Allow);
183        assert_eq!(policy.thresholds.len(), 3);
184        assert_eq!(policy.thresholds[0].action, PolicyAction::Block);
185        assert_eq!(
186            policy.thresholds[1].action,
187            PolicyAction::ReRoute {
188                provider: "ollama".to_string()
189            }
190        );
191        assert_eq!(policy.thresholds[2].action, PolicyAction::Warn);
192    }
193
194    #[test]
195    fn service_descriptor_data_policy_survives_serde_roundtrip() {
196        // Regression for catalog-sync dropping unknown fields: a known
197        // `data_policy` must round-trip through parse → serialize unchanged.
198        let catalog = crate::provider::ProviderIndex::embedded();
199        let mut svc = catalog.get("zai").expect("zai in catalog").clone();
200        svc.data_policy = Some(DataPolicy {
201            image: ImagePolicy::Strip,
202            thresholds: vec![PolicyThreshold {
203                min_sensitivity: Sensitivity::Internal,
204                action: PolicyAction::Warn,
205            }],
206        });
207        let json = serde_json::to_string_pretty(&svc).expect("serialize");
208        let back: crate::provider::ServiceDescriptor =
209            serde_json::from_str(&json).expect("deserialize");
210        assert_eq!(back.data_policy, svc.data_policy);
211    }
212}