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#[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}