Skip to main content

fd_policy/
rules.rs

1//! Policy rules
2
3use serde::{Deserialize, Serialize};
4
5use crate::precedence::{PolicyVerdict, VerdictKind};
6
7/// A tool allowlist rule
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct ToolAllowlist {
10    /// Allowed tool names (exact match)
11    pub allowed_tools: Vec<String>,
12
13    /// Tools that require approval before execution
14    pub approval_required: Vec<String>,
15
16    /// Tools that are explicitly denied
17    pub denied_tools: Vec<String>,
18}
19
20impl ToolAllowlist {
21    /// Legacy short-circuit check kept for back-compat with existing
22    /// callers and tests. Newer code should consult [`Self::matches`] +
23    /// [`crate::precedence::resolve_conflicts`] instead so it picks up
24    /// the explanation trace.
25    pub fn check(&self, tool_name: &str) -> ToolAllowlistResult {
26        // Explicit deny takes precedence
27        if self.denied_tools.iter().any(|t| t == tool_name) {
28            return ToolAllowlistResult::Denied;
29        }
30
31        // Check if approval is required
32        if self.approval_required.iter().any(|t| t == tool_name) {
33            return ToolAllowlistResult::RequiresApproval;
34        }
35
36        // Check if explicitly allowed
37        if self.allowed_tools.iter().any(|t| t == tool_name) {
38            return ToolAllowlistResult::Allowed;
39        }
40
41        // Deny by default
42        ToolAllowlistResult::Denied
43    }
44
45    /// Return every matching verdict for `tool_name`, instead of short-
46    /// circuiting on the first hit. Order: denied → approval → allowed.
47    /// Empty result means the allowlist saw nothing — the caller is
48    /// responsible for the deny-by-default fallback.
49    pub fn matches(&self, tool_name: &str) -> Vec<PolicyVerdict> {
50        let mut out = Vec::new();
51        if self.denied_tools.iter().any(|t| t == tool_name) {
52            out.push(PolicyVerdict::new(
53                VerdictKind::Deny,
54                "allowlist:denied",
55                format!("tool '{}' is on the explicit denylist", tool_name),
56            ));
57        }
58        if self.approval_required.iter().any(|t| t == tool_name) {
59            out.push(PolicyVerdict::new(
60                VerdictKind::RequiresApproval,
61                "allowlist:approval",
62                format!("tool '{}' requires approval before execution", tool_name),
63            ));
64        }
65        if self.allowed_tools.iter().any(|t| t == tool_name) {
66            out.push(PolicyVerdict::new(
67                VerdictKind::Allow,
68                "allowlist:allowed",
69                format!("tool '{}' is in the allowlist", tool_name),
70            ));
71        }
72        out
73    }
74}
75
76/// Result of checking a tool against the allowlist
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum ToolAllowlistResult {
79    Allowed,
80    RequiresApproval,
81    Denied,
82}
83
84/// Risk classification for tools
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87#[derive(Default)]
88pub enum ToolRiskLevel {
89    /// Read-only operations
90    Low,
91    /// Mutations with limited blast radius
92    #[default]
93    Medium,
94    /// External communications, writes to important systems
95    High,
96    /// Payments, deployments, security-sensitive
97    Critical,
98}