fleetforge_policy/
tool_acl.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{anyhow, Result};
5use async_trait::async_trait;
6use serde_json::Value;
7
8use crate::{Decision, DecisionEffect, PolicyEngine, PolicyRequest};
9
10#[derive(Clone, Default)]
11pub struct ToolAclPolicy {
12    allow: Option<HashSet<String>>,
13    deny: Option<HashSet<String>>,
14    allow_images: Option<HashSet<String>>,
15    allow_networks: Option<HashSet<String>>,
16}
17
18impl ToolAclPolicy {
19    pub fn from_config(config: &Value) -> Result<Self> {
20        let mut policy = Self::default();
21        if let Some(allow) = config.get("allow") {
22            policy.allow = Some(parse_set(allow)?);
23        }
24        if let Some(deny) = config.get("deny") {
25            policy.deny = Some(parse_set(deny)?);
26        }
27        if let Some(images) = config.get("allow_images") {
28            policy.allow_images = Some(parse_set(images)?);
29        }
30        if let Some(networks) = config.get("allow_networks") {
31            policy.allow_networks = Some(parse_set(networks)?);
32        }
33        Ok(policy)
34    }
35}
36
37fn parse_set(value: &Value) -> Result<HashSet<String>> {
38    let array = value
39        .as_array()
40        .ok_or_else(|| anyhow!("tool ACL config expects an array of strings"))?;
41    let mut set = HashSet::with_capacity(array.len());
42    for entry in array {
43        let item = entry
44            .as_str()
45            .ok_or_else(|| anyhow!("tool ACL entries must be strings"))?
46            .trim()
47            .to_ascii_lowercase();
48        if !item.is_empty() {
49            set.insert(item);
50        }
51    }
52    Ok(set)
53}
54
55fn normalize_command(command: &str) -> String {
56    Path::new(command)
57        .file_name()
58        .map(|name| name.to_string_lossy().to_string())
59        .unwrap_or_else(|| command.to_string())
60        .to_ascii_lowercase()
61}
62
63fn collect_tool_identifiers(payload: &Value) -> Vec<String> {
64    let mut ids = Vec::new();
65    if let Some(step) = payload.get("step") {
66        if let Some(slug) = step.get("slug").and_then(Value::as_str) {
67            ids.push(slug.trim().to_ascii_lowercase());
68        }
69        if let Some(id) = step.get("id").and_then(Value::as_str) {
70            ids.push(id.trim().to_ascii_lowercase());
71        }
72    }
73    if let Some(inputs) = payload.get("inputs") {
74        if let Some(tool) = inputs.get("tool").and_then(Value::as_str) {
75            ids.push(tool.trim().to_ascii_lowercase());
76        }
77        if let Some(command) = inputs
78            .get("command")
79            .and_then(Value::as_array)
80            .and_then(|arr| arr.first())
81            .and_then(Value::as_str)
82        {
83            ids.push(normalize_command(command));
84        }
85    }
86    ids.retain(|value| !value.is_empty());
87    ids
88}
89
90fn normalize_option(value: Option<&Value>) -> Option<String> {
91    value
92        .and_then(Value::as_str)
93        .map(|s| s.trim().to_ascii_lowercase())
94        .filter(|s| !s.is_empty())
95}
96
97#[async_trait]
98impl PolicyEngine for ToolAclPolicy {
99    async fn evaluate(&self, request: &PolicyRequest) -> Result<Decision> {
100        if self.allow.is_none()
101            && self.deny.is_none()
102            && self.allow_images.is_none()
103            && self.allow_networks.is_none()
104        {
105            return Ok(Decision::allow());
106        }
107
108        let boundary = request
109            .context()
110            .get("boundary")
111            .and_then(Value::as_str)
112            .unwrap_or_default()
113            .to_ascii_lowercase();
114        if boundary != "ingress_tool" {
115            return Ok(Decision::allow());
116        }
117
118        let payload = request
119            .context()
120            .get("payload")
121            .cloned()
122            .unwrap_or(Value::Null);
123
124        let step_is_tool = payload
125            .get("step")
126            .and_then(|step| step.get("type").and_then(Value::as_str))
127            .map(|kind| kind.eq_ignore_ascii_case("tool"))
128            .unwrap_or(true);
129        if !step_is_tool {
130            return Ok(Decision::allow());
131        }
132
133        let identifiers = collect_tool_identifiers(&payload);
134
135        if let Some(ref allow) = self.allow {
136            if !identifiers.iter().any(|id| allow.contains(id)) {
137                return Ok(Decision {
138                    effect: DecisionEffect::Deny,
139                    reason: Some("tool_acl_denied".into()),
140                    patches: Vec::new(),
141                });
142            }
143        }
144
145        if let Some(ref deny) = self.deny {
146            if identifiers.iter().any(|id| deny.contains(id)) {
147                return Ok(Decision {
148                    effect: DecisionEffect::Deny,
149                    reason: Some("tool_acl_denied".into()),
150                    patches: Vec::new(),
151                });
152            }
153        }
154
155        if let Some(ref allowed_images) = self.allow_images {
156            if let Some(image) =
157                normalize_option(payload.get("inputs").and_then(|i| i.get("image")))
158            {
159                if !allowed_images.contains(&image) {
160                    return Ok(Decision {
161                        effect: DecisionEffect::Deny,
162                        reason: Some("tool_image_not_allowed".into()),
163                        patches: Vec::new(),
164                    });
165                }
166            }
167        }
168
169        if let Some(ref allowed_networks) = self.allow_networks {
170            if let Some(network) =
171                normalize_option(payload.get("inputs").and_then(|i| i.get("network")))
172            {
173                if !allowed_networks.contains(&network) {
174                    return Ok(Decision {
175                        effect: DecisionEffect::Deny,
176                        reason: Some("tool_network_not_allowed".into()),
177                        patches: Vec::new(),
178                    });
179                }
180            }
181        }
182
183        Ok(Decision::allow())
184    }
185}