fleetforge_policy/packs/
default.rs

1use std::sync::Arc;
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use once_cell::sync::OnceCell;
6use serde::Deserialize;
7use serde_json::{Map, Value};
8
9use crate::{
10    budget::BudgetCapsPolicy, packs::prompt_injection, tool_acl::ToolAclPolicy, Decision,
11    PolicyEngine, PolicyRequest,
12};
13
14#[derive(Debug, Clone, Deserialize, Default)]
15#[serde(rename_all = "snake_case", deny_unknown_fields)]
16pub struct GovernConfig {
17    #[serde(default)]
18    pub allow_tools: Option<Vec<String>>,
19    #[serde(default)]
20    pub deny_tools: Option<Vec<String>>,
21    #[serde(default)]
22    pub allow_images: Option<Vec<String>>,
23    #[serde(default)]
24    pub allow_networks: Option<Vec<String>>,
25}
26
27#[derive(Debug, Clone, Deserialize, Default)]
28#[serde(rename_all = "snake_case", deny_unknown_fields)]
29pub struct MeasureConfig {
30    #[serde(default)]
31    pub max_tokens: Option<i64>,
32    #[serde(default)]
33    pub max_cost: Option<f64>,
34    #[serde(default)]
35    pub warn_ratio: Option<f64>,
36}
37
38#[derive(Debug, Clone, Deserialize, Default)]
39#[serde(rename_all = "snake_case", deny_unknown_fields)]
40pub struct DefaultPolicyConfig {
41    #[serde(default)]
42    pub govern: Option<GovernConfig>,
43    #[serde(default)]
44    pub measure: Option<MeasureConfig>,
45}
46
47#[derive(Clone)]
48pub struct DefaultPack {
49    prompt_policy: Arc<dyn PolicyEngine>,
50    tool_acl: Option<ToolAclPolicy>,
51    budget: Option<BudgetCapsPolicy>,
52}
53
54static CONFIG_ENV: &str = "FLEETFORGE_POLICY_DEFAULT";
55
56impl DefaultPack {
57    pub fn from_env() -> Result<Self> {
58        let raw = std::env::var(CONFIG_ENV).ok();
59        let cfg = match raw.as_deref().map(str::trim) {
60            Some("") | None => DefaultPolicyConfig::default(),
61            Some(json) => serde_json::from_str(json).with_context(|| {
62                format!("failed to parse default policy config from {CONFIG_ENV}")
63            })?,
64        };
65        Self::from_config(cfg)
66    }
67
68    pub fn from_config(config: DefaultPolicyConfig) -> Result<Self> {
69        let prompt_policy = prompt_injection::embedded()
70            .context("failed to load embedded prompt injection policy")?;
71
72        let tool_acl = config
73            .govern
74            .as_ref()
75            .map(|govern| {
76                let mut value = Map::new();
77                if let Some(ref allow) = govern.allow_tools {
78                    value.insert("allow".to_string(), serde_json::to_value(allow)?);
79                }
80                if let Some(ref deny) = govern.deny_tools {
81                    value.insert("deny".to_string(), serde_json::to_value(deny)?);
82                }
83                if let Some(ref allow_images) = govern.allow_images {
84                    value.insert(
85                        "allow_images".to_string(),
86                        serde_json::to_value(allow_images)?,
87                    );
88                }
89                if let Some(ref allow_networks) = govern.allow_networks {
90                    value.insert(
91                        "allow_networks".to_string(),
92                        serde_json::to_value(allow_networks)?,
93                    );
94                }
95                if value.is_empty() {
96                    Ok(None)
97                } else {
98                    ToolAclPolicy::from_config(&Value::Object(value)).map(Some)
99                }
100            })
101            .transpose()?
102            .flatten();
103
104        let budget = config
105            .measure
106            .as_ref()
107            .map(|measure| {
108                let mut value = Map::new();
109                if let Some(max_tokens) = measure.max_tokens {
110                    value.insert("max_tokens".to_string(), Value::from(max_tokens));
111                }
112                if let Some(max_cost) = measure.max_cost {
113                    value.insert("max_cost".to_string(), Value::from(max_cost));
114                }
115                if let Some(warn_ratio) = measure.warn_ratio {
116                    value.insert("warn_ratio".to_string(), Value::from(warn_ratio));
117                }
118                if value.is_empty() {
119                    Ok(None)
120                } else {
121                    BudgetCapsPolicy::from_config(&Value::Object(value)).map(Some)
122                }
123            })
124            .transpose()?
125            .flatten();
126
127        Ok(Self {
128            prompt_policy,
129            tool_acl,
130            budget,
131        })
132    }
133}
134
135#[async_trait]
136impl PolicyEngine for DefaultPack {
137    async fn evaluate(&self, request: &PolicyRequest) -> Result<Decision> {
138        let mut decisions = Vec::new();
139        decisions.push(self.prompt_policy.evaluate(request).await?);
140        if let Some(tool_acl) = &self.tool_acl {
141            decisions.push(tool_acl.evaluate(request).await?);
142        }
143        if let Some(budget) = &self.budget {
144            decisions.push(budget.evaluate(request).await?);
145        }
146
147        if decisions.is_empty() {
148            return Ok(Decision::allow());
149        }
150
151        Ok(Decision::merge(decisions))
152    }
153}
154
155pub fn shared_default_pack() -> Result<Arc<dyn PolicyEngine>> {
156    static CACHE: OnceCell<Arc<dyn PolicyEngine>> = OnceCell::new();
157    CACHE
158        .get_or_try_init(|| {
159            DefaultPack::from_env().map(|pack| Arc::new(pack) as Arc<dyn PolicyEngine>)
160        })
161        .map(Arc::clone)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use fleetforge_policy::{DecisionEffect, PolicyRequest};
168    use serde_json::json;
169    use uuid::Uuid;
170
171    fn request_for_command(command: &str) -> PolicyRequest {
172        let payload = json!({
173            "step": {
174                "type": "tool"
175            },
176            "inputs": {
177                "command": [command]
178            },
179            "policy": {}
180        });
181        PolicyRequest::new(Uuid::new_v4(), Uuid::new_v4(), payload)
182    }
183
184    #[tokio::test]
185    async fn default_pack_allows_with_no_config() {
186        let pack = DefaultPack::from_config(DefaultPolicyConfig::default())
187            .expect("default pack should load");
188        let request = request_for_command("echo");
189        let decision = pack.evaluate(&request).await.expect("policy decision");
190        assert_eq!(decision.effect, DecisionEffect::Allow);
191    }
192
193    #[tokio::test]
194    async fn govern_config_enforces_tool_acl() {
195        let config = DefaultPolicyConfig {
196            govern: Some(GovernConfig {
197                allow_tools: Some(vec!["echo".into()]),
198                deny_tools: None,
199                allow_images: None,
200                allow_networks: None,
201            }),
202            measure: None,
203        };
204        let pack = DefaultPack::from_config(config).expect("govern config should parse");
205        let deny_request = request_for_command("rm");
206        let decision = pack.evaluate(&deny_request).await.expect("decision");
207        assert_eq!(decision.effect, DecisionEffect::Deny);
208        let allow_request = request_for_command("echo");
209        let decision = pack.evaluate(&allow_request).await.expect("decision");
210        assert_eq!(decision.effect, DecisionEffect::Allow);
211    }
212}