fleetforge_policy/packs/
default.rs1use 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}