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.tokens_spent + ctx.next_tool_tokens > self.limits.max_tokens {
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_tokens: 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 { tokens_spent: 500, next_tool_tokens: 1, ..base_context() };
164 assert!(matches!(
165 policy().evaluate(&ctx),
166 PolicyDecision::Deny { reason: StopReason::BudgetExhausted }
167 ));
168 }
169
170 #[test]
171 fn denies_when_next_call_would_exceed_budget() {
172 let ctx = PolicyContext { tokens_spent: 491, next_tool_tokens: 10, ..base_context() };
174 assert!(matches!(
175 policy().evaluate(&ctx),
176 PolicyDecision::Deny { reason: StopReason::BudgetExhausted }
177 ));
178 }
179
180 #[test]
181 fn denies_unlisted_tool() {
182 let ctx = PolicyContext {
183 requested_tool: Some("write_file".to_string()),
184 ..base_context()
185 };
186 assert!(matches!(
187 policy().evaluate(&ctx),
188 PolicyDecision::Deny { reason: StopReason::ToolDenied { .. } }
189 ));
190 }
191
192 #[test]
193 fn allows_listed_tool() {
194 let ctx = PolicyContext {
195 requested_tool: Some("http_get".to_string()),
196 ..base_context()
197 };
198 assert!(matches!(policy().evaluate(&ctx), PolicyDecision::Allow));
199 }
200
201 #[test]
202 fn step_limit_checked_before_timeout() {
203 let ctx = PolicyContext { step_count: 10, elapsed_ms: 99_999, ..base_context() };
204 assert!(matches!(
205 policy().evaluate(&ctx),
206 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
207 ));
208 }
209
210 fn rule_evaluator_with_http_get_limit(max: u32) -> RuleEvaluator {
211 let mut map = HashMap::new();
212 map.insert("http_get".to_string(), max);
213 RuleEvaluator::new(map)
214 }
215
216 #[test]
217 fn rule_evaluator_allows_when_under_limit() {
218 let re = rule_evaluator_with_http_get_limit(3);
219 let mut counts = HashMap::new();
220 counts.insert("http_get".to_string(), 2u32);
221 let ctx = PolicyContext {
222 requested_tool: Some("http_get".to_string()),
223 tool_call_counts: counts,
224 ..base_context()
225 };
226 assert!(matches!(re.evaluate(&ctx), PolicyDecision::Allow));
227 }
228
229 #[test]
230 fn rule_evaluator_denies_at_max_calls() {
231 let re = rule_evaluator_with_http_get_limit(3);
232 let mut counts = HashMap::new();
233 counts.insert("http_get".to_string(), 3u32);
234 let ctx = PolicyContext {
235 requested_tool: Some("http_get".to_string()),
236 tool_call_counts: counts,
237 ..base_context()
238 };
239 assert!(matches!(
240 re.evaluate(&ctx),
241 PolicyDecision::Deny {
242 reason: StopReason::RuleDenied { ref rule_name }
243 } if rule_name == "http_get.max_calls"
244 ));
245 }
246
247 #[test]
248 fn rule_evaluator_ignores_unconfigured_tools() {
249 let re = rule_evaluator_with_http_get_limit(1);
250 let ctx = PolicyContext {
251 requested_tool: Some("write_file".to_string()),
252 ..base_context()
253 };
254 assert!(matches!(re.evaluate(&ctx), PolicyDecision::Allow));
255 }
256
257 #[test]
258 fn rule_evaluator_allows_when_no_tool_requested() {
259 let re = rule_evaluator_with_http_get_limit(1);
260 assert!(matches!(re.evaluate(&base_context()), PolicyDecision::Allow));
261 }
262
263 #[test]
264 fn chain_allows_when_both_allow() {
265 let chain = ChainPolicy::new(
266 RuleEvaluator::new(HashMap::new()),
267 RuleEvaluator::new(HashMap::new()),
268 );
269 assert!(matches!(chain.evaluate(&base_context()), PolicyDecision::Allow));
270 }
271
272 #[test]
273 fn chain_denies_when_first_denies() {
274 let first = LimitsPolicy::new(
275 Limits { max_steps: 0, max_tokens: 999, timeout_ms: 99_999 },
276 vec![],
277 );
278 let second = RuleEvaluator::new(HashMap::new());
279 let chain = ChainPolicy::new(first, second);
280 assert!(matches!(
281 chain.evaluate(&base_context()),
282 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
283 ));
284 }
285
286 #[test]
287 fn chain_denies_when_second_denies() {
288 let first = RuleEvaluator::new(HashMap::new());
289 let re = rule_evaluator_with_http_get_limit(1);
290 let chain = ChainPolicy::new(first, re);
291 let mut counts = HashMap::new();
292 counts.insert("http_get".to_string(), 1u32);
293 let ctx = PolicyContext {
294 requested_tool: Some("http_get".to_string()),
295 tool_call_counts: counts,
296 ..base_context()
297 };
298 assert!(matches!(
299 chain.evaluate(&ctx),
300 PolicyDecision::Deny { reason: StopReason::RuleDenied { .. } }
301 ));
302 }
303
304 #[test]
305 fn chain_first_denial_wins_over_second() {
306 let first = LimitsPolicy::new(
307 Limits { max_steps: 0, max_tokens: 999, timeout_ms: 99_999 },
308 vec![],
309 );
310 let mut max_calls = HashMap::new();
311 max_calls.insert("http_get".to_string(), 0u32);
312 let second = RuleEvaluator::new(max_calls);
313 let chain = ChainPolicy::new(first, second);
314 let ctx = PolicyContext {
315 requested_tool: Some("http_get".to_string()),
316 ..base_context()
317 };
318 assert!(matches!(
319 chain.evaluate(&ctx),
320 PolicyDecision::Deny { reason: StopReason::MaxStepsReached }
321 ));
322 }
323}