Skip to main content

oxios_kernel/approval/
gate.rs

1//! ApprovalGate — runtime approval evaluation.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde_json::Value;
7
8use super::policy::{ApprovalConfig, ApprovalMode, DEFAULT_TOOL_POLICIES, ToolPolicy};
9use super::resolver::{GlobalResolver, ToolPolicyResolver};
10
11/// Tool call context for approval evaluation.
12pub struct ToolCall<'a> {
13    /// Tool name ("exec", "read", "web_search" ...).
14    pub tool: &'a str,
15    /// For exec: the binary ("curl") or "shell".
16    pub binary: Option<&'a str>,
17    /// Raw call arguments (used by dynamic resolvers / blacklist matching).
18    pub args: &'a Value,
19}
20
21impl ToolCall<'_> {
22    /// Grant key. Hybrid: tool + (exec binary).
23    pub fn grant_key(&self) -> String {
24        match self.tool {
25            "exec" => format!("exec:{}", self.binary.unwrap_or("shell")),
26            other => other.to_string(),
27        }
28    }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ApprovalDecision {
33    /// Execute immediately.
34    Allow,
35    /// Surface an approval card to the user.
36    RequireApproval { reason: String },
37}
38
39pub struct ApprovalGate {
40    tool_policies: HashMap<String, ToolPolicy>,
41    config: Arc<parking_lot::RwLock<ApprovalConfig>>,
42    global_resolvers: Vec<Box<dyn GlobalResolver>>,
43    /// Per-tool dynamic resolvers. Consulted in Phase 2.5 of the pipeline —
44    /// after `tool_overrides` (Phase 2) and before `global_resolvers`
45    /// (Phase 3). The resolver's policy replaces the base policy when
46    /// present, but an explicit `Always` (declared or override) is sticky
47    /// — the resolver must never relax it. Phase 3 global resolvers
48    /// (blacklist, audit, ...) still max-merge on top so they can always
49    /// escalate back to `Always`.
50    dynamic_resolvers: HashMap<String, Box<dyn ToolPolicyResolver>>,
51}
52
53impl ApprovalGate {
54    pub fn new(tool_policies: HashMap<String, ToolPolicy>, config: ApprovalConfig) -> Self {
55        Self::with_global_resolvers(tool_policies, config, Vec::new())
56    }
57    pub fn with_global_resolvers(
58        tool_policies: HashMap<String, ToolPolicy>,
59        config: ApprovalConfig,
60        global_resolvers: Vec<Box<dyn GlobalResolver>>,
61    ) -> Self {
62        Self {
63            tool_policies,
64            config: Arc::new(parking_lot::RwLock::new(config)),
65            global_resolvers,
66            dynamic_resolvers: HashMap::new(),
67        }
68    }
69    pub fn with_shared_config(
70        tool_policies: HashMap<String, ToolPolicy>,
71        config: Arc<parking_lot::RwLock<ApprovalConfig>>,
72        global_resolvers: Vec<Box<dyn GlobalResolver>>,
73    ) -> Self {
74        Self {
75            tool_policies,
76            config,
77            global_resolvers,
78            dynamic_resolvers: HashMap::new(),
79        }
80    }
81    /// Construct a gate with both global resolvers and per-tool dynamic
82    /// resolvers. Used by `agent_runtime` to inject the
83    /// `ExecPolicyResolver` so structured exec with an allowed binary
84    /// runs without approval in `Manual` mode.
85    pub fn with_dynamic_resolvers(
86        tool_policies: HashMap<String, ToolPolicy>,
87        config: Arc<parking_lot::RwLock<ApprovalConfig>>,
88        global_resolvers: Vec<Box<dyn GlobalResolver>>,
89        dynamic_resolvers: HashMap<String, Box<dyn ToolPolicyResolver>>,
90    ) -> Self {
91        Self {
92            tool_policies,
93            config,
94            global_resolvers,
95            dynamic_resolvers,
96        }
97    }
98
99    pub fn evaluate(&self, call: &ToolCall<'_>) -> ApprovalDecision {
100        let config = self.config.read();
101        // Phase 1: declared policy for the tool.
102        let mut policy = self
103            .tool_policies
104            .get(call.tool)
105            .copied()
106            .unwrap_or(ToolPolicy::OnDemand);
107        // Phase 2: config-level tool overrides.
108        if let Some(&override_p) = config.tool_overrides.get(call.tool) {
109            policy = override_p;
110        }
111        // Phase 2.5: per-tool dynamic resolver (e.g. ExecPolicyResolver).
112        // The resolver has per-call knowledge and replaces the base policy
113        // when it returns `Some(_)`. This lets ExecPolicyResolver relax a
114        // declared `OnDemand` policy to `Auto` for a specific allowed
115        // binary. The pipeline-wide blacklist (Phase 3) still max-merges
116        // on top, so it can always escalate back to `Always`.
117        //
118        // Invariant: `Always` is sticky. If the user explicitly demanded
119        // "always prompt for this tool" via a `tool_overrides` entry (or a
120        // Phase 1 declaration), the dynamic resolver must NOT relax it to
121        // `OnDemand`/`Auto` — that would silently bypass the user's intent.
122        if let Some(resolver) = self.dynamic_resolvers.get(call.tool)
123            && let Some(p) = resolver.resolve(call.args)
124        {
125            policy = if policy == ToolPolicy::Always {
126                ToolPolicy::Always
127            } else {
128                p
129            };
130        }
131        // Phase 3: pipeline-wide global resolvers (blacklist, audit, ...).
132        for resolver in &self.global_resolvers {
133            if let Some(p) = resolver.resolve(call) {
134                policy = policy.max(p);
135            }
136        }
137        // Phase 4: mode × policy table.
138        use {ApprovalMode::*, ToolPolicy::*};
139        match (config.mode, policy) {
140            (_, Auto) => ApprovalDecision::Allow,
141            (_, Always) => require(call, "always-policy tool"),
142            (AutoRun, OnDemand) => ApprovalDecision::Allow,
143            (AllowList, OnDemand) if config.allow_list.iter().any(|k| k == &call.grant_key()) => {
144                ApprovalDecision::Allow
145            }
146            (_, OnDemand) => require(call, "approval required"),
147        }
148    }
149}
150
151fn require(call: &ToolCall<'_>, why: &str) -> ApprovalDecision {
152    ApprovalDecision::RequireApproval {
153        reason: format!("{}: {}", call.tool, why),
154    }
155}
156
157/// Build the default tool_policies map from the const table.
158pub fn default_tool_policy_map() -> HashMap<String, ToolPolicy> {
159    DEFAULT_TOOL_POLICIES
160        .iter()
161        .map(|(n, p)| (n.to_string(), *p))
162        .collect()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::super::policy::{ApprovalConfig, ApprovalMode::*, ToolPolicy};
168    use super::*;
169    use serde_json::json;
170    use std::collections::HashMap;
171
172    fn gate(mode: ApprovalMode, allow_list: &[&str]) -> ApprovalGate {
173        let policies = default_tool_policy_map();
174        let config = ApprovalConfig {
175            mode,
176            allow_list: allow_list.iter().map(|s| s.to_string()).collect(),
177            tool_overrides: HashMap::new(),
178        };
179        ApprovalGate::new(policies, config)
180    }
181
182    fn call<'a>(tool: &'a str, binary: Option<&'a str>) -> ToolCall<'a> {
183        static EMPTY_ARGS: std::sync::LazyLock<serde_json::Value> =
184            std::sync::LazyLock::new(|| serde_json::json!({}));
185        ToolCall {
186            tool,
187            binary,
188            args: &EMPTY_ARGS,
189        }
190    }
191    // Auto tools: always allow, regardless of mode.
192    #[test]
193    fn auto_allow_in_manual() {
194        assert!(matches!(
195            gate(Manual, &[]).evaluate(&call("read", None)),
196            ApprovalDecision::Allow
197        ));
198    }
199    #[test]
200    fn auto_allow_in_allowlist() {
201        assert!(matches!(
202            gate(AllowList, &[]).evaluate(&call("read", None)),
203            ApprovalDecision::Allow
204        ));
205    }
206    #[test]
207    fn auto_allow_in_autorun() {
208        assert!(matches!(
209            gate(AutoRun, &[]).evaluate(&call("read", None)),
210            ApprovalDecision::Allow
211        ));
212    }
213
214    // OnDemand + AutoRun → Allow
215    #[test]
216    fn ondemand_autorun_allows() {
217        assert!(matches!(
218            gate(AutoRun, &[]).evaluate(&call("exec", Some("curl"))),
219            ApprovalDecision::Allow
220        ));
221    }
222
223    // OnDemand + AllowList → Allow iff grant
224    #[test]
225    fn ondemand_allowlist_grant_allows() {
226        assert!(matches!(
227            gate(AllowList, &["exec:curl"]).evaluate(&call("exec", Some("curl"))),
228            ApprovalDecision::Allow
229        ));
230    }
231    #[test]
232    fn ondemand_allowlist_no_grant_prompts() {
233        assert!(matches!(
234            gate(AllowList, &[]).evaluate(&call("exec", Some("curl"))),
235            ApprovalDecision::RequireApproval { .. }
236        ));
237    }
238
239    // OnDemand + Manual → prompt
240    #[test]
241    fn ondemand_manual_prompts() {
242        assert!(matches!(
243            gate(Manual, &[]).evaluate(&call("exec", Some("curl"))),
244            ApprovalDecision::RequireApproval { .. }
245        ));
246    }
247
248    // tool_overrides escalate to Always → prompt even in AutoRun
249    #[test]
250    fn always_override_prompts_in_autorun() {
251        let policies = default_tool_policy_map();
252        let mut overrides = HashMap::new();
253        overrides.insert("exec".to_string(), ToolPolicy::Always);
254        let config = ApprovalConfig {
255            mode: AutoRun,
256            allow_list: vec![],
257            tool_overrides: overrides,
258        };
259        let g = ApprovalGate::new(policies, config);
260        assert!(matches!(
261            g.evaluate(&call("exec", Some("curl"))),
262            ApprovalDecision::RequireApproval { .. }
263        ));
264    }
265
266    // security blacklist escalates to Always even with override to Auto
267    #[test]
268    fn blacklist_beats_auto_override() {
269        let policies = default_tool_policy_map();
270        let mut overrides = HashMap::new();
271        overrides.insert("exec".to_string(), ToolPolicy::Auto); // user tries to weaken
272        let config = ApprovalConfig {
273            mode: AutoRun,
274            allow_list: vec![],
275            tool_overrides: overrides,
276        };
277        let blacklist = super::super::blacklist::SecurityBlacklist::new(
278            super::super::blacklist::default_blacklist_rules(),
279        );
280        let g = ApprovalGate::with_global_resolvers(policies, config, vec![Box::new(blacklist)]);
281        let args = json!({"mode": "shell", "command": "rm -rf /etc"});
282        let rm_call = ToolCall {
283            tool: "exec",
284            binary: None,
285            args: &args,
286        };
287        assert!(matches!(
288            g.evaluate(&rm_call),
289            ApprovalDecision::RequireApproval { .. }
290        ));
291    }
292
293    // A user-set `Always` override must stay sticky across the dynamic
294    // resolver. The dynamic resolver is allowed to relax a declared
295    // `OnDemand` (e.g. ExecPolicyResolver returning `Auto` for an allowed
296    // binary) but must NOT relax an explicit `Always` — that would silently
297    // bypass the user's "always prompt" demand.
298    #[test]
299    fn always_override_sticks_across_dynamic_resolver() {
300        use super::super::resolver::ToolPolicyResolver;
301        let policies = default_tool_policy_map();
302        let mut overrides = HashMap::new();
303        overrides.insert("exec".to_string(), ToolPolicy::Always);
304        let config = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
305            mode: AutoRun,
306            allow_list: vec![],
307            tool_overrides: overrides,
308        }));
309        // Always returns Auto — would relax an OnDemand, but not an Always.
310        struct AlwaysAuto;
311        impl ToolPolicyResolver for AlwaysAuto {
312            fn resolve(&self, _args: &serde_json::Value) -> Option<ToolPolicy> {
313                Some(ToolPolicy::Auto)
314            }
315        }
316        let mut dynamic = HashMap::new();
317        dynamic.insert(
318            "exec".to_string(),
319            Box::new(AlwaysAuto) as Box<dyn ToolPolicyResolver>,
320        );
321        let g = ApprovalGate::with_dynamic_resolvers(policies, config, vec![], dynamic);
322        assert!(
323            matches!(
324                g.evaluate(&call("exec", Some("curl"))),
325                ApprovalDecision::RequireApproval { .. }
326            ),
327            "explicit Always must NOT be relaxed by the dynamic resolver"
328        );
329    }
330
331    #[test]
332    fn grant_key_exec_includes_binary() {
333        assert_eq!(call("exec", Some("curl")).grant_key(), "exec:curl");
334        assert_eq!(call("exec", None).grant_key(), "exec:shell");
335        assert_eq!(call("read", None).grant_key(), "read");
336    }
337    #[test]
338    fn shared_config_mutation_takes_effect_live() {
339        let shared = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
340            mode: ApprovalMode::AllowList,
341            allow_list: vec![],
342            tool_overrides: HashMap::new(),
343        }));
344        let gate =
345            ApprovalGate::with_shared_config(default_tool_policy_map(), shared.clone(), vec![]);
346        let curl = call("exec", Some("curl"));
347        assert!(matches!(
348            gate.evaluate(&curl),
349            ApprovalDecision::RequireApproval { .. }
350        ));
351        shared.write().allow_list.push("exec:curl".to_string());
352        assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
353        shared.write().allow_list.clear();
354        shared.write().mode = ApprovalMode::AutoRun;
355        assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
356    }
357}