Skip to main content

soma_application/
capabilities.rs

1//! Capability brokering: the [`CapabilityBroker`] decides which host
2//! capabilities a provider dispatch may use, defaulting to deny.
3use soma_provider_core::{
4    BrowserCapability, CapabilityGrant, EnvCapability, FilesystemCapability, GitHubCapability,
5    HostCapabilities, NetworkCapability, TerminalCapability,
6};
7
8use crate::provider_errors::ProviderError;
9
10/// Checks provider-requested host capabilities against a fixed set of policy
11/// grants, denying any capability that is not explicitly allowed.
12#[derive(Debug, Clone, Default)]
13pub struct CapabilityBroker {
14    grants: Vec<CapabilityGrant>,
15}
16
17impl CapabilityBroker {
18    /// Builds a broker backed by the given policy grants.
19    pub fn new(grants: Vec<CapabilityGrant>) -> Self {
20        Self { grants }
21    }
22
23    /// Builds a broker with no grants, denying every host capability.
24    pub fn default_deny() -> Self {
25        Self::default()
26    }
27
28    /// Authorizes a provider action's requested host capabilities, returning
29    /// an error for the first requested capability no grant covers.
30    pub fn authorize(
31        &self,
32        provider: &str,
33        action: &str,
34        requested: &HostCapabilities,
35    ) -> Result<(), ProviderError> {
36        if let Some(capability) = enabled_filesystem(requested) {
37            let granted = self.grants.iter().any(|grant| match grant {
38                CapabilityGrant::Filesystem {
39                    read_roots,
40                    write_roots,
41                } => {
42                    all_paths_allowed(&capability.read_roots, read_roots)
43                        && all_paths_allowed(&capability.write_roots, write_roots)
44                }
45                _ => false,
46            });
47            if !granted {
48                return Err(denied(provider, action, "filesystem"));
49            }
50        }
51        if let Some(capability) = enabled_network(requested) {
52            let granted = self.grants.iter().any(|grant| match grant {
53                CapabilityGrant::Network { allowed_hosts } => {
54                    all_items_allowed(&capability.allowed_hosts, allowed_hosts)
55                }
56                _ => false,
57            });
58            if !granted {
59                return Err(denied(provider, action, "network"));
60            }
61        }
62        if let Some(capability) = enabled_env(requested) {
63            let granted = self.grants.iter().any(|grant| match grant {
64                CapabilityGrant::Env { allowed } => all_items_allowed(&capability.allowed, allowed),
65                _ => false,
66            });
67            if !granted {
68                return Err(denied(provider, action, "env"));
69            }
70        }
71        if let Some(capability) = enabled_terminal(requested) {
72            let granted = self.grants.iter().any(|grant| match grant {
73                CapabilityGrant::Terminal {
74                    working_dir,
75                    allowlist,
76                } => {
77                    working_dir_allows(capability.working_dir.as_deref(), working_dir.as_deref())
78                        && all_items_allowed(&capability.allowlist, allowlist)
79                }
80                _ => false,
81            });
82            if !granted {
83                return Err(denied(provider, action, "terminal"));
84            }
85        }
86        if let Some(capability) = enabled_browser(requested) {
87            let granted = self.grants.iter().any(|grant| match grant {
88                CapabilityGrant::Browser { allowed_origins } => {
89                    all_items_allowed(&capability.allowed_origins, allowed_origins)
90                }
91                _ => false,
92            });
93            if !granted {
94                return Err(denied(provider, action, "browser"));
95            }
96        }
97        if let Some(capability) = enabled_github(requested) {
98            let granted = self.grants.iter().any(|grant| match grant {
99                CapabilityGrant::Github {
100                    allowed_repos,
101                    read_only,
102                } => {
103                    all_items_allowed(&capability.allowed_repos, allowed_repos)
104                        && (capability.read_only || !read_only)
105                }
106                _ => false,
107            });
108            if !granted {
109                return Err(denied(provider, action, "github"));
110            }
111        }
112        Ok(())
113    }
114}
115
116fn enabled_filesystem(requested: &HostCapabilities) -> Option<&FilesystemCapability> {
117    requested.filesystem.as_ref().filter(|cap| cap.enabled)
118}
119
120fn enabled_network(requested: &HostCapabilities) -> Option<&NetworkCapability> {
121    requested.network.as_ref().filter(|cap| cap.enabled)
122}
123
124fn enabled_env(requested: &HostCapabilities) -> Option<&EnvCapability> {
125    requested.env.as_ref().filter(|cap| cap.enabled)
126}
127
128fn enabled_terminal(requested: &HostCapabilities) -> Option<&TerminalCapability> {
129    requested.terminal.as_ref().filter(|cap| cap.enabled)
130}
131
132fn enabled_browser(requested: &HostCapabilities) -> Option<&BrowserCapability> {
133    requested.browser.as_ref().filter(|cap| cap.enabled)
134}
135
136fn enabled_github(requested: &HostCapabilities) -> Option<&GitHubCapability> {
137    requested.github.as_ref().filter(|cap| cap.enabled)
138}
139
140fn all_items_allowed(requested: &[String], allowed: &[String]) -> bool {
141    requested
142        .iter()
143        .all(|item| allowed.iter().any(|grant| grant == item))
144}
145
146fn all_paths_allowed(requested: &[String], allowed: &[String]) -> bool {
147    requested
148        .iter()
149        .all(|item| allowed.iter().any(|grant| path_allows(item, grant)))
150}
151
152fn path_allows(requested: &str, grant: &str) -> bool {
153    requested == grant
154        || requested
155            .strip_prefix(grant.trim_end_matches('/'))
156            .map(|suffix| suffix.starts_with('/'))
157            .unwrap_or(false)
158}
159
160fn working_dir_allows(requested: Option<&str>, granted: Option<&str>) -> bool {
161    match (requested, granted) {
162        (None, _) => true,
163        (Some(_), None) => false,
164        (Some(requested), Some(granted)) => path_allows(requested, granted),
165    }
166}
167
168fn denied(provider: &str, action: &str, capability: &str) -> ProviderError {
169    ProviderError::new(
170        "capability_denied",
171        provider,
172        Some(action.to_owned()),
173        format!("provider requested denied {capability} capability"),
174        "Grant the specific host capability in policy or disable the provider action.",
175    )
176}