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