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 (cost units)
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.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
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_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}