nanny_runtime/
enforcement.rs1use nanny_core::agent::{limits::Limits, state::StopReason};
10use nanny_core::policy::{Policy, PolicyContext, PolicyDecision};
11use std::collections::HashMap;
12
13pub struct LimitsPolicy {
26 limits: Limits,
27 allowed_tools: Vec<String>,
28}
29
30impl LimitsPolicy {
31 pub fn new(limits: Limits, allowed_tools: Vec<String>) -> Self {
32 Self { limits, allowed_tools }
33 }
34}
35
36impl Policy for LimitsPolicy {
37 fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision {
38 if ctx.step_count >= self.limits.max_steps {
39 return PolicyDecision::Deny { reason: StopReason::MaxStepsReached };
40 }
41 if ctx.elapsed_ms >= self.limits.timeout_ms {
42 return PolicyDecision::Deny { reason: StopReason::TimeoutExpired };
43 }
44 if ctx.cost_units_spent >= self.limits.max_cost_units {
45 return PolicyDecision::Deny { reason: StopReason::BudgetExhausted };
46 }
47 if let Some(tool) = &ctx.requested_tool {
48 if !self.allowed_tools.contains(tool) {
49 return PolicyDecision::Deny {
50 reason: StopReason::ToolDenied { tool_name: tool.clone() },
51 };
52 }
53 }
54 PolicyDecision::Allow
55 }
56}
57
58pub struct RuleEvaluator {
67 max_calls: HashMap<String, u32>,
68}
69
70impl RuleEvaluator {
71 pub fn new(max_calls: HashMap<String, u32>) -> Self {
72 Self { max_calls }
73 }
74}
75
76impl Policy for RuleEvaluator {
77 fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision {
78 let tool = match &ctx.requested_tool {
79 Some(t) => t,
80 None => return PolicyDecision::Allow,
81 };
82 if let Some(&max) = self.max_calls.get(tool) {
83 let calls_so_far = ctx.tool_call_counts.get(tool).copied().unwrap_or(0);
84 if calls_so_far >= max {
85 return PolicyDecision::Deny {
86 reason: StopReason::RuleDenied {
87 rule_name: format!("{tool}.max_calls"),
88 },
89 };
90 }
91 }
92 PolicyDecision::Allow
93 }
94}
95
96pub struct ChainPolicy<A, B> {
100 first: A,
101 second: B,
102}
103
104impl<A, B> ChainPolicy<A, B> {
105 pub fn new(first: A, second: B) -> Self {
106 Self { first, second }
107 }
108}
109
110impl<A: Policy, B: Policy> Policy for ChainPolicy<A, B> {
111 fn evaluate(&self, ctx: &PolicyContext) -> PolicyDecision {
112 match self.first.evaluate(ctx) {
113 PolicyDecision::Allow => self.second.evaluate(ctx),
114 deny => deny,
115 }
116 }
117}
118
119#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn base_limits() -> Limits {
126 Limits { max_steps: 10, max_cost_units: 500, timeout_ms: 10_000 }
127 }
128
129 fn base_context() -> PolicyContext {
130 PolicyContext::default()
131 }
132
133 fn policy() -> LimitsPolicy {
134 LimitsPolicy::new(base_limits(), vec!["http_get".to_string()])
135 }
136
137 #[test]
138 fn allows_within_limits() {
139 assert!(matches!(policy().evaluate(&base_context()), PolicyDecision::Allow));
140 }
141
142 #[test]
143 fn denies_at_max_steps() {
144 let ctx = PolicyContext { step_count: 10, ..base_context() };
145 assert!(matches!(
146 policy().evaluate(&ctx),
147 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
148 ));
149 }
150
151 #[test]
152 fn denies_on_timeout() {
153 let ctx = PolicyContext { elapsed_ms: 10_001, ..base_context() };
154 assert!(matches!(
155 policy().evaluate(&ctx),
156 PolicyDecision::Deny { reason: StopReason::TimeoutExpired }
157 ));
158 }
159
160 #[test]
161 fn denies_on_budget_exhausted() {
162 let ctx = PolicyContext { cost_units_spent: 500, ..base_context() };
163 assert!(matches!(
164 policy().evaluate(&ctx),
165 PolicyDecision::Deny { reason: StopReason::BudgetExhausted }
166 ));
167 }
168
169 #[test]
170 fn denies_unlisted_tool() {
171 let ctx = PolicyContext {
172 requested_tool: Some("write_file".to_string()),
173 ..base_context()
174 };
175 assert!(matches!(
176 policy().evaluate(&ctx),
177 PolicyDecision::Deny { reason: StopReason::ToolDenied { .. } }
178 ));
179 }
180
181 #[test]
182 fn allows_listed_tool() {
183 let ctx = PolicyContext {
184 requested_tool: Some("http_get".to_string()),
185 ..base_context()
186 };
187 assert!(matches!(policy().evaluate(&ctx), PolicyDecision::Allow));
188 }
189
190 #[test]
191 fn step_limit_checked_before_timeout() {
192 let ctx = PolicyContext { step_count: 10, elapsed_ms: 99_999, ..base_context() };
193 assert!(matches!(
194 policy().evaluate(&ctx),
195 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
196 ));
197 }
198
199 fn rule_evaluator_with_http_get_limit(max: u32) -> RuleEvaluator {
200 let mut map = HashMap::new();
201 map.insert("http_get".to_string(), max);
202 RuleEvaluator::new(map)
203 }
204
205 #[test]
206 fn rule_evaluator_allows_when_under_limit() {
207 let re = rule_evaluator_with_http_get_limit(3);
208 let mut counts = HashMap::new();
209 counts.insert("http_get".to_string(), 2u32);
210 let ctx = PolicyContext {
211 requested_tool: Some("http_get".to_string()),
212 tool_call_counts: counts,
213 ..base_context()
214 };
215 assert!(matches!(re.evaluate(&ctx), PolicyDecision::Allow));
216 }
217
218 #[test]
219 fn rule_evaluator_denies_at_max_calls() {
220 let re = rule_evaluator_with_http_get_limit(3);
221 let mut counts = HashMap::new();
222 counts.insert("http_get".to_string(), 3u32);
223 let ctx = PolicyContext {
224 requested_tool: Some("http_get".to_string()),
225 tool_call_counts: counts,
226 ..base_context()
227 };
228 assert!(matches!(
229 re.evaluate(&ctx),
230 PolicyDecision::Deny {
231 reason: StopReason::RuleDenied { ref rule_name }
232 } if rule_name == "http_get.max_calls"
233 ));
234 }
235
236 #[test]
237 fn rule_evaluator_ignores_unconfigured_tools() {
238 let re = rule_evaluator_with_http_get_limit(1);
239 let ctx = PolicyContext {
240 requested_tool: Some("write_file".to_string()),
241 ..base_context()
242 };
243 assert!(matches!(re.evaluate(&ctx), PolicyDecision::Allow));
244 }
245
246 #[test]
247 fn rule_evaluator_allows_when_no_tool_requested() {
248 let re = rule_evaluator_with_http_get_limit(1);
249 assert!(matches!(re.evaluate(&base_context()), PolicyDecision::Allow));
250 }
251
252 #[test]
253 fn chain_allows_when_both_allow() {
254 let chain = ChainPolicy::new(
255 RuleEvaluator::new(HashMap::new()),
256 RuleEvaluator::new(HashMap::new()),
257 );
258 assert!(matches!(chain.evaluate(&base_context()), PolicyDecision::Allow));
259 }
260
261 #[test]
262 fn chain_denies_when_first_denies() {
263 let first = LimitsPolicy::new(
264 Limits { max_steps: 0, max_cost_units: 999, timeout_ms: 99_999 },
265 vec![],
266 );
267 let second = RuleEvaluator::new(HashMap::new());
268 let chain = ChainPolicy::new(first, second);
269 assert!(matches!(
270 chain.evaluate(&base_context()),
271 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
272 ));
273 }
274
275 #[test]
276 fn chain_denies_when_second_denies() {
277 let first = RuleEvaluator::new(HashMap::new());
278 let re = rule_evaluator_with_http_get_limit(1);
279 let chain = ChainPolicy::new(first, re);
280 let mut counts = HashMap::new();
281 counts.insert("http_get".to_string(), 1u32);
282 let ctx = PolicyContext {
283 requested_tool: Some("http_get".to_string()),
284 tool_call_counts: counts,
285 ..base_context()
286 };
287 assert!(matches!(
288 chain.evaluate(&ctx),
289 PolicyDecision::Deny { reason: StopReason::RuleDenied { .. } }
290 ));
291 }
292
293 #[test]
294 fn chain_first_denial_wins_over_second() {
295 let first = LimitsPolicy::new(
296 Limits { max_steps: 0, max_cost_units: 999, timeout_ms: 99_999 },
297 vec![],
298 );
299 let mut max_calls = HashMap::new();
300 max_calls.insert("http_get".to_string(), 0u32);
301 let second = RuleEvaluator::new(max_calls);
302 let chain = ChainPolicy::new(first, second);
303 let ctx = PolicyContext {
304 requested_tool: Some("http_get".to_string()),
305 ..base_context()
306 };
307 assert!(matches!(
308 chain.evaluate(&ctx),
309 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
310 ));
311 }
312}