fleetforge_policy/packs/
regulated.rs

1use std::{collections::HashSet, env, path::Path, sync::Arc};
2
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::Value;
6use tracing::debug;
7
8use fleetforge_common::licensing::{ensure_feature_allowed, LicensedFeature};
9
10use crate::{BasicPiiPolicy, Decision, DecisionEffect, PiiMode, PolicyEngine, PolicyRequest};
11
12/// Regulated verticals supported by the policy pack.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Vertical {
15    Hipaa,
16    Gdpr,
17}
18
19#[derive(Clone)]
20pub struct RegulatedPack {
21    vertical: Vertical,
22    pii: Arc<BasicPiiPolicy>,
23    allowed_commands: Arc<HashSet<String>>,
24    allowed_images: Arc<HashSet<String>>,
25    allowed_networks: Arc<HashSet<String>>,
26}
27
28impl RegulatedPack {
29    pub fn new(vertical: Vertical) -> Result<Self> {
30        ensure_feature_allowed(LicensedFeature::RegulatedPolicies)?;
31        Ok(Self {
32            vertical,
33            pii: Arc::new(BasicPiiPolicy::new(PiiMode::Redact)),
34            allowed_commands: Arc::new(Self::build_allowed_commands()),
35            allowed_images: Arc::new(Self::build_allowed_images()),
36            allowed_networks: Arc::new(Self::build_allowed_networks(vertical)),
37        })
38    }
39
40    fn deny(reason: &str) -> Decision {
41        Decision {
42            effect: DecisionEffect::Deny,
43            reason: Some(reason.into()),
44            patches: Vec::new(),
45        }
46    }
47
48    fn allow() -> Decision {
49        Decision::allow()
50    }
51
52    fn is_known_phi(value: &Value) -> bool {
53        value
54            .as_object()
55            .and_then(|obj| obj.get("guardrails"))
56            .and_then(Value::as_array)
57            .map(|arr| arr.iter().any(|v| v == "phi"))
58            .unwrap_or(false)
59    }
60
61    fn build_allowed_commands() -> HashSet<String> {
62        let mut commands: HashSet<String> = ["echo", "python", "python3", "jq"]
63            .into_iter()
64            .map(|s| s.to_ascii_lowercase())
65            .collect();
66
67        if let Ok(extra) = env::var("FLEETFORGE_ALLOWED_TOOLS") {
68            for entry in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) {
69                commands.insert(entry.to_ascii_lowercase());
70            }
71        }
72
73        commands
74    }
75
76    fn build_allowed_images() -> HashSet<String> {
77        let mut images: HashSet<String> =
78            ["ghcr.io/fleetforge/toolbox:latest".to_ascii_lowercase()]
79                .into_iter()
80                .collect();
81
82        if let Ok(toolbox) = env::var("FLEETFORGE_TOOLBOX_IMAGE") {
83            if !toolbox.trim().is_empty() {
84                images.insert(toolbox.trim().to_ascii_lowercase());
85            }
86        }
87
88        if let Ok(extra) = env::var("FLEETFORGE_ALLOWED_IMAGES") {
89            for entry in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) {
90                images.insert(entry.to_ascii_lowercase());
91            }
92        }
93
94        images
95    }
96
97    fn build_allowed_networks(vertical: Vertical) -> HashSet<String> {
98        let mut networks: HashSet<String> = match vertical {
99            Vertical::Hipaa => ["none"]
100                .into_iter()
101                .map(|s| s.to_ascii_lowercase())
102                .collect(),
103            Vertical::Gdpr => ["none", "loopback"]
104                .into_iter()
105                .map(|s| s.to_ascii_lowercase())
106                .collect(),
107        };
108
109        if let Ok(extra) = env::var("FLEETFORGE_ALLOWED_NETWORKS") {
110            for entry in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) {
111                networks.insert(entry.to_ascii_lowercase());
112            }
113        }
114
115        networks
116    }
117
118    fn enforce_tool_policy(&self, context: &Value) -> Option<Decision> {
119        let step = context.get("step")?;
120        let step_type = step
121            .get("type")
122            .and_then(Value::as_str)
123            .unwrap_or_default()
124            .to_ascii_lowercase();
125        if step_type != "tool" {
126            return None;
127        }
128
129        let command = step
130            .get("inputs")
131            .and_then(|inputs| inputs.get("command"))
132            .and_then(Value::as_array)
133            .and_then(|arr| arr.first())
134            .and_then(Value::as_str)
135            .map(|value| {
136                Path::new(value)
137                    .file_name()
138                    .map(|name| name.to_string_lossy().to_string())
139                    .unwrap_or_else(|| value.to_string())
140                    .to_ascii_lowercase()
141            });
142
143        if let Some(command) = command {
144            if !self.allowed_commands.contains(&command) {
145                return Some(Self::deny("tool_command_not_allowed"));
146            }
147        }
148
149        if let Some(image) = step
150            .get("inputs")
151            .and_then(|inputs| inputs.get("image"))
152            .and_then(Value::as_str)
153            .map(str::trim)
154            .filter(|value| !value.is_empty())
155        {
156            if !self.allowed_images.contains(&image.to_ascii_lowercase()) {
157                return Some(Self::deny("tool_image_not_allowed"));
158            }
159        }
160
161        if let Some(network) = step
162            .get("inputs")
163            .and_then(|inputs| inputs.get("network"))
164            .and_then(Value::as_str)
165            .map(str::trim)
166            .filter(|value| !value.is_empty())
167        {
168            let normalized = network.to_ascii_lowercase();
169            if !self.allowed_networks.contains(&normalized) {
170                return Some(Self::deny("tool_network_not_allowed"));
171            }
172        }
173
174        None
175    }
176}
177
178#[async_trait]
179impl PolicyEngine for RegulatedPack {
180    async fn evaluate(&self, request: &PolicyRequest) -> Result<Decision> {
181        let decision = self.pii.evaluate(request).await?;
182        if matches!(decision.effect, DecisionEffect::Redact) {
183            debug!(run = %request.run_id(), step = %request.step_id(), "PII redaction requested by regulated pack");
184        }
185
186        match self.vertical {
187            Vertical::Hipaa => {
188                if Self::is_known_phi(request.context()) {
189                    return Ok(Self::deny("phi_guardrail_denied"));
190                }
191
192                if let Some(guard) = self.enforce_tool_policy(request.context()) {
193                    return Ok(guard);
194                }
195
196                if matches!(decision.effect, DecisionEffect::Redact) {
197                    return Ok(decision);
198                }
199
200                Ok(Self::allow())
201            }
202            Vertical::Gdpr => {
203                if let Some(access) = request
204                    .context()
205                    .get("policy")
206                    .and_then(|p| p.get("subject_access"))
207                    .and_then(Value::as_bool)
208                {
209                    if access {
210                        return Ok(Self::deny("subject_access_request_pending"));
211                    }
212                }
213
214                if let Some(guard) = self.enforce_tool_policy(request.context()) {
215                    return Ok(guard);
216                }
217
218                if matches!(decision.effect, DecisionEffect::Redact) {
219                    return Ok(decision);
220                }
221
222                Ok(Self::allow())
223            }
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use serde_json::json;
232    use uuid::Uuid;
233
234    fn reset_policy_env() {
235        std::env::remove_var("FLEETFORGE_ALLOWED_TOOLS");
236        std::env::remove_var("FLEETFORGE_TOOLBOX_IMAGE");
237        std::env::remove_var("FLEETFORGE_ALLOWED_IMAGES");
238        std::env::remove_var("FLEETFORGE_ALLOWED_NETWORKS");
239    }
240
241    fn regulated_pack(vertical: Vertical) -> RegulatedPack {
242        let previous = std::env::var("FLEETFORGE_LICENSE_TIER").ok();
243        std::env::set_var("FLEETFORGE_LICENSE_TIER", "enterprise");
244        let pack =
245            RegulatedPack::new(vertical).expect("regulated pack should load under enterprise tier");
246        match previous {
247            Some(value) => std::env::set_var("FLEETFORGE_LICENSE_TIER", value),
248            None => std::env::remove_var("FLEETFORGE_LICENSE_TIER"),
249        }
250        pack
251    }
252
253    fn request_with_inputs(inputs: Value) -> PolicyRequest {
254        PolicyRequest::new(
255            Uuid::new_v4(),
256            Uuid::new_v4(),
257            json!({
258                "inputs": inputs,
259            }),
260        )
261    }
262
263    #[tokio::test]
264    async fn hipaa_deny_on_phi_guardrail() {
265        reset_policy_env();
266        let pack = regulated_pack(Vertical::Hipaa);
267        let request = PolicyRequest::new(
268            Uuid::new_v4(),
269            Uuid::new_v4(),
270            json!({
271                "inputs": {"patient": "Jane"},
272                "policy": {"guardrails": ["phi"]}
273            }),
274        );
275        let decision = pack.evaluate(&request).await.unwrap();
276        assert_eq!(decision.effect, DecisionEffect::Deny);
277    }
278
279    #[tokio::test]
280    async fn hipaa_allows_non_phi() {
281        reset_policy_env();
282        let pack = regulated_pack(Vertical::Hipaa);
283        let request = request_with_inputs(json!({"note": "non phi"}));
284        let decision = pack.evaluate(&request).await.unwrap();
285        assert_eq!(decision.effect, DecisionEffect::Allow);
286    }
287
288    #[tokio::test]
289    async fn gdpr_denies_subject_access_flag() {
290        reset_policy_env();
291        let pack = regulated_pack(Vertical::Gdpr);
292        let request = PolicyRequest::new(
293            Uuid::new_v4(),
294            Uuid::new_v4(),
295            json!({
296                "inputs": {},
297                "policy": {"subject_access": true}
298            }),
299        );
300        let decision = pack.evaluate(&request).await.unwrap();
301        assert_eq!(decision.effect, DecisionEffect::Deny);
302    }
303
304    #[tokio::test]
305    async fn gdpr_redacts_pii() {
306        reset_policy_env();
307        let pack = regulated_pack(Vertical::Gdpr);
308        let request = request_with_inputs(json!({"email": "person@example.com"}));
309        let decision = pack.evaluate(&request).await.unwrap();
310        assert_eq!(decision.effect, DecisionEffect::Redact);
311    }
312
313    #[tokio::test]
314    async fn regulated_denies_unapproved_tool_command() {
315        reset_policy_env();
316        let pack = regulated_pack(Vertical::Hipaa);
317        let request = PolicyRequest::new(
318            Uuid::new_v4(),
319            Uuid::new_v4(),
320            json!({
321                "step": {
322                    "type": "tool",
323                    "inputs": { "command": ["rm", "-rf", "/"] },
324                    "policy": {}
325                },
326                "inputs": { "command": ["rm", "-rf", "/"] },
327                "policy": {}
328            }),
329        );
330        let decision = pack.evaluate(&request).await.unwrap();
331        assert_eq!(decision.effect, DecisionEffect::Deny);
332        assert_eq!(decision.reason.as_deref(), Some("tool_command_not_allowed"));
333    }
334
335    #[tokio::test]
336    async fn regulated_denies_unapproved_network() {
337        reset_policy_env();
338        let pack = regulated_pack(Vertical::Gdpr);
339        let request = PolicyRequest::new(
340            Uuid::new_v4(),
341            Uuid::new_v4(),
342            json!({
343                "step": {
344                    "type": "tool",
345                    "inputs": {
346                        "command": ["echo", "ok"],
347                        "network": "bridge"
348                    },
349                    "policy": {}
350                },
351                "inputs": {
352                    "command": ["echo", "ok"],
353                    "network": "bridge"
354                },
355                "policy": {}
356            }),
357        );
358        let decision = pack.evaluate(&request).await.unwrap();
359        assert_eq!(decision.effect, DecisionEffect::Deny);
360        assert_eq!(decision.reason.as_deref(), Some("tool_network_not_allowed"));
361    }
362
363    #[tokio::test]
364    async fn regulated_allows_default_toolbox_command() {
365        reset_policy_env();
366        let pack = regulated_pack(Vertical::Gdpr);
367        let request = PolicyRequest::new(
368            Uuid::new_v4(),
369            Uuid::new_v4(),
370            json!({
371                "step": {
372                    "type": "tool",
373                    "inputs": {
374                        "command": ["echo", "hi"]
375                    },
376                    "policy": {}
377                },
378                "inputs": {
379                    "command": ["echo", "hi"]
380                },
381                "policy": {}
382            }),
383        );
384        let decision = pack.evaluate(&request).await.unwrap();
385        assert_eq!(decision.effect, DecisionEffect::Allow);
386    }
387
388    #[tokio::test]
389    async fn regulated_denies_unapproved_image() {
390        reset_policy_env();
391        let pack = regulated_pack(Vertical::Hipaa);
392        let request = PolicyRequest::new(
393            Uuid::new_v4(),
394            Uuid::new_v4(),
395            json!({
396                "step": {
397                    "type": "tool",
398                    "inputs": {
399                        "command": ["echo", "hi"],
400                        "image": "registry.example.com/custom/toolbox:1"
401                    },
402                    "policy": {}
403                },
404                "inputs": {
405                    "command": ["echo", "hi"],
406                    "image": "registry.example.com/custom/toolbox:1"
407                },
408                "policy": {}
409            }),
410        );
411        let decision = pack.evaluate(&request).await.unwrap();
412        assert_eq!(decision.effect, DecisionEffect::Deny);
413        assert_eq!(decision.reason.as_deref(), Some("tool_image_not_allowed"));
414    }
415}