Skip to main content

oxios_kernel/approval/
resolver.rs

1//! Tool policy resolvers — dynamic (per-call) and global (pipeline-wide).
2
3use std::sync::Arc;
4
5use parking_lot::RwLock;
6use serde_json::Value;
7
8use super::policy::ToolPolicy;
9
10/// Per-tool dynamic resolver: returns `Some(policy)` to override the declared
11/// default based on call arguments, or `None` to keep the declared policy.
12/// lobehub `resolveDynamicPolicy` analog.
13pub trait ToolPolicyResolver: Send + Sync {
14    fn resolve(&self, args: &Value) -> Option<ToolPolicy>;
15}
16
17/// Pipeline-wide resolver (Phase 3 of the evaluation). Security blacklist,
18/// audit, rate-limit, etc. Return `Some(policy)` to escalate; the gate adopts
19/// it via `ToolPolicy::max`, so it can only strengthen — never weaken.
20pub trait GlobalResolver: Send + Sync {
21    fn resolve(&self, call: &super::gate::ToolCall<'_>) -> Option<ToolPolicy>;
22}
23
24/// ExecTool dynamic policy. Preserves current behavior:
25/// structured + allowed_commands binary → Auto; shell / unknown → OnDemand.
26pub struct ExecPolicyResolver {
27    /// Mirror of `ExecConfig.allowed_commands`. Updated when config reloads.
28    pub allowed_commands: Arc<RwLock<Vec<String>>>,
29}
30
31impl ToolPolicyResolver for ExecPolicyResolver {
32    fn resolve(&self, args: &Value) -> Option<ToolPolicy> {
33        let mode = args.get("mode")?.as_str()?;
34        let command = args
35            .get("command")
36            .and_then(|v| v.as_str())
37            .or_else(|| args.get("binary").and_then(|v| v.as_str()))?;
38        let allowed = self.allowed_commands.read();
39        match mode {
40            "structured" if allowed.iter().any(|c| c == command) => Some(ToolPolicy::Auto),
41            _ => Some(ToolPolicy::OnDemand),
42        }
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use serde_json::json;
50
51    fn resolver(allowed: &[&str]) -> ExecPolicyResolver {
52        ExecPolicyResolver {
53            allowed_commands: std::sync::Arc::new(parking_lot::RwLock::new(
54                allowed.iter().map(|s| s.to_string()).collect(),
55            )),
56        }
57    }
58
59    #[test]
60    fn structured_allowed_binary_is_auto() {
61        let r = resolver(&["curl", "ls"]);
62        assert_eq!(
63            r.resolve(&json!({"mode": "structured", "binary": "curl"})),
64            Some(ToolPolicy::Auto)
65        );
66    }
67
68    #[test]
69    fn structured_unknown_binary_is_ondemand() {
70        let r = resolver(&["curl"]);
71        assert_eq!(
72            r.resolve(&json!({"mode": "structured", "binary": "rm"})),
73            Some(ToolPolicy::OnDemand)
74        );
75    }
76
77    #[test]
78    fn shell_mode_is_ondemand() {
79        let r = resolver(&["curl"]);
80        assert_eq!(
81            r.resolve(&json!({"mode": "shell", "command": "ls -la"})),
82            Some(ToolPolicy::OnDemand)
83        );
84    }
85
86    #[test]
87    fn missing_mode_returns_none() {
88        let r = resolver(&["curl"]);
89        assert_eq!(r.resolve(&json!({"binary": "curl"})), None);
90    }
91}