Skip to main content

nanny_runtime/
enforcement.rs

1// enforcement.rs — Concrete policy implementations.
2//
3// These are the enforcement decisions. The contract (Policy trait, PolicyContext,
4// PolicyDecision) lives in nanny-core.
5//
6// Rule: all implementations here are pure functions.
7// Same context in → same decision out. Always. No exceptions.
8
9use nanny_core::agent::{limits::Limits, state::StopReason};
10use nanny_core::policy::{Policy, PolicyContext, PolicyDecision};
11use std::collections::HashMap;
12
13// ── LimitsPolicy ──────────────────────────────────────────────────────────────
14
15/// The standard policy for a single execution.
16///
17/// Enforces all four hard limits:
18///   1. Maximum step count
19///   2. Wall-clock timeout
20///   3. Budget (tokens)
21///   4. Tool allowlist
22///
23/// Checks are evaluated in order. The first failing check stops execution.
24/// All checks are pure — no state is mutated, no network calls are made.
25pub 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
58// ── RuleEvaluator ─────────────────────────────────────────────────────────────
59
60/// Enforces per-tool rules declared in nanny.toml under [tools.<name>].
61///
62/// Currently enforces:
63///   - `max_calls`: deny once a tool has been called max_calls times
64///
65/// Always runs after LimitsPolicy — compose them with ChainPolicy.
66pub 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
96// ── ChainPolicy ───────────────────────────────────────────────────────────────
97
98/// Composes two policies in sequence. First denial wins.
99pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
120
121#[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        // Budget fully spent; any new call (tokens=1) is denied.
163        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        // budget=500, spent=491, next call costs 10: 491+10 > 500 → denied before execution.
173        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}