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        let has_grant = config.allow_list.iter().any(|k| k == &call.grant_key());
140        match (config.mode, policy) {
141            (_, Auto) => ApprovalDecision::Allow,
142            (_, Always) => require(call, "always-policy tool"),
143            (AutoRun, OnDemand) => ApprovalDecision::Allow,
144            (AllowList, OnDemand) if has_grant => ApprovalDecision::Allow,
145            // An explicit user grant ("don't ask again") is honored in Manual
146            // mode too. Without this, approving a tool once never sticks —
147            // every subsequent call re-prompts, which contradicts the card's
148            // "allow in this session" promise. Always / blacklist tools still
149            // override via the arms above.
150            (Manual, OnDemand) if has_grant => ApprovalDecision::Allow,
151            (_, OnDemand) => require(call, "approval required"),
152        }
153    }
154}
155
156fn require(call: &ToolCall<'_>, why: &str) -> ApprovalDecision {
157    ApprovalDecision::RequireApproval {
158        reason: format!("{}: {}", call.tool, why),
159    }
160}
161
162/// Build the default tool_policies map from the const table.
163pub fn default_tool_policy_map() -> HashMap<String, ToolPolicy> {
164    DEFAULT_TOOL_POLICIES
165        .iter()
166        .map(|(n, p)| (n.to_string(), *p))
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::super::policy::{ApprovalConfig, ApprovalMode::*, ToolPolicy};
173    use super::*;
174    use serde_json::json;
175    use std::collections::HashMap;
176
177    fn gate(mode: ApprovalMode, allow_list: &[&str]) -> ApprovalGate {
178        let policies = default_tool_policy_map();
179        let config = ApprovalConfig {
180            mode,
181            allow_list: allow_list.iter().map(|s| s.to_string()).collect(),
182            tool_overrides: HashMap::new(),
183        };
184        ApprovalGate::new(policies, config)
185    }
186
187    fn call<'a>(tool: &'a str, binary: Option<&'a str>) -> ToolCall<'a> {
188        static EMPTY_ARGS: std::sync::LazyLock<serde_json::Value> =
189            std::sync::LazyLock::new(|| serde_json::json!({}));
190        ToolCall {
191            tool,
192            binary,
193            args: &EMPTY_ARGS,
194        }
195    }
196    // Auto tools: always allow, regardless of mode.
197    #[test]
198    fn auto_allow_in_manual() {
199        assert!(matches!(
200            gate(Manual, &[]).evaluate(&call("read", None)),
201            ApprovalDecision::Allow
202        ));
203    }
204    #[test]
205    fn auto_allow_in_allowlist() {
206        assert!(matches!(
207            gate(AllowList, &[]).evaluate(&call("read", None)),
208            ApprovalDecision::Allow
209        ));
210    }
211    #[test]
212    fn auto_allow_in_autorun() {
213        assert!(matches!(
214            gate(AutoRun, &[]).evaluate(&call("read", None)),
215            ApprovalDecision::Allow
216        ));
217    }
218
219    // OnDemand + AutoRun → Allow
220    #[test]
221    fn ondemand_autorun_allows() {
222        assert!(matches!(
223            gate(AutoRun, &[]).evaluate(&call("exec", Some("curl"))),
224            ApprovalDecision::Allow
225        ));
226    }
227
228    // OnDemand + AllowList → Allow iff grant
229    #[test]
230    fn ondemand_allowlist_grant_allows() {
231        assert!(matches!(
232            gate(AllowList, &["exec:curl"]).evaluate(&call("exec", Some("curl"))),
233            ApprovalDecision::Allow
234        ));
235    }
236    #[test]
237    fn ondemand_allowlist_no_grant_prompts() {
238        assert!(matches!(
239            gate(AllowList, &[]).evaluate(&call("exec", Some("curl"))),
240            ApprovalDecision::RequireApproval { .. }
241        ));
242    }
243
244    // OnDemand + Manual → prompt
245    #[test]
246    fn ondemand_manual_prompts() {
247        assert!(matches!(
248            gate(Manual, &[]).evaluate(&call("exec", Some("curl"))),
249            ApprovalDecision::RequireApproval { .. }
250        ));
251    }
252
253    // OnDemand + Manual + explicit grant → Allow. The card's "allow in this
254    // session" promise must hold: once a user grants a tool (via the
255    // "don't ask again" checkbox), Manual mode honors it instead of
256    // re-prompting on every call.
257    #[test]
258    fn ondemand_manual_grant_allows() {
259        assert!(matches!(
260            gate(Manual, &["web_search"]).evaluate(&call("web_search", None)),
261            ApprovalDecision::Allow
262        ));
263    }
264
265    // tool_overrides escalate to Always → prompt even in AutoRun
266    #[test]
267    fn always_override_prompts_in_autorun() {
268        let policies = default_tool_policy_map();
269        let mut overrides = HashMap::new();
270        overrides.insert("exec".to_string(), ToolPolicy::Always);
271        let config = ApprovalConfig {
272            mode: AutoRun,
273            allow_list: vec![],
274            tool_overrides: overrides,
275        };
276        let g = ApprovalGate::new(policies, config);
277        assert!(matches!(
278            g.evaluate(&call("exec", Some("curl"))),
279            ApprovalDecision::RequireApproval { .. }
280        ));
281    }
282
283    // security blacklist escalates to Always even with override to Auto
284    #[test]
285    fn blacklist_beats_auto_override() {
286        let policies = default_tool_policy_map();
287        let mut overrides = HashMap::new();
288        overrides.insert("exec".to_string(), ToolPolicy::Auto); // user tries to weaken
289        let config = ApprovalConfig {
290            mode: AutoRun,
291            allow_list: vec![],
292            tool_overrides: overrides,
293        };
294        let blacklist = super::super::blacklist::SecurityBlacklist::new(
295            super::super::blacklist::default_blacklist_rules(),
296        );
297        let g = ApprovalGate::with_global_resolvers(policies, config, vec![Box::new(blacklist)]);
298        let args = json!({"mode": "shell", "command": "rm -rf /etc"});
299        let rm_call = ToolCall {
300            tool: "exec",
301            binary: None,
302            args: &args,
303        };
304        assert!(matches!(
305            g.evaluate(&rm_call),
306            ApprovalDecision::RequireApproval { .. }
307        ));
308    }
309
310    // A user-set `Always` override must stay sticky across the dynamic
311    // resolver. The dynamic resolver is allowed to relax a declared
312    // `OnDemand` (e.g. ExecPolicyResolver returning `Auto` for an allowed
313    // binary) but must NOT relax an explicit `Always` — that would silently
314    // bypass the user's "always prompt" demand.
315    #[test]
316    fn always_override_sticks_across_dynamic_resolver() {
317        use super::super::resolver::ToolPolicyResolver;
318        let policies = default_tool_policy_map();
319        let mut overrides = HashMap::new();
320        overrides.insert("exec".to_string(), ToolPolicy::Always);
321        let config = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
322            mode: AutoRun,
323            allow_list: vec![],
324            tool_overrides: overrides,
325        }));
326        // Always returns Auto — would relax an OnDemand, but not an Always.
327        struct AlwaysAuto;
328        impl ToolPolicyResolver for AlwaysAuto {
329            fn resolve(&self, _args: &serde_json::Value) -> Option<ToolPolicy> {
330                Some(ToolPolicy::Auto)
331            }
332        }
333        let mut dynamic = HashMap::new();
334        dynamic.insert(
335            "exec".to_string(),
336            Box::new(AlwaysAuto) as Box<dyn ToolPolicyResolver>,
337        );
338        let g = ApprovalGate::with_dynamic_resolvers(policies, config, vec![], dynamic);
339        assert!(
340            matches!(
341                g.evaluate(&call("exec", Some("curl"))),
342                ApprovalDecision::RequireApproval { .. }
343            ),
344            "explicit Always must NOT be relaxed by the dynamic resolver"
345        );
346    }
347
348    #[test]
349    fn grant_key_exec_includes_binary() {
350        assert_eq!(call("exec", Some("curl")).grant_key(), "exec:curl");
351        assert_eq!(call("exec", None).grant_key(), "exec:shell");
352        assert_eq!(call("read", None).grant_key(), "read");
353    }
354    #[test]
355    fn shared_config_mutation_takes_effect_live() {
356        let shared = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
357            mode: ApprovalMode::AllowList,
358            allow_list: vec![],
359            tool_overrides: HashMap::new(),
360        }));
361        let gate =
362            ApprovalGate::with_shared_config(default_tool_policy_map(), shared.clone(), vec![]);
363        let curl = call("exec", Some("curl"));
364        assert!(matches!(
365            gate.evaluate(&curl),
366            ApprovalDecision::RequireApproval { .. }
367        ));
368        shared.write().allow_list.push("exec:curl".to_string());
369        assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
370        shared.write().allow_list.clear();
371        shared.write().mode = ApprovalMode::AutoRun;
372        assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
373    }
374}