Skip to main content

astrid_plugins/
security.rs

1//! Security gate trait for plugin host function calls.
2//!
3//! Decouples the plugin WASM runtime from the full security interceptor stack.
4//! Test implementations ([`AllowAllGate`], [`DenyAllGate`]) are provided for
5//! unit testing. A concrete [`SecurityInterceptorGate`] adapter wrapping
6//! `astrid-approval`'s `SecurityInterceptor` is available behind the
7//! `approval` feature flag.
8
9use async_trait::async_trait;
10
11/// Security gate for plugin host function calls.
12///
13/// Each method corresponds to a class of sensitive operation that a WASM
14/// plugin can request through host functions. Implementors decide whether
15/// to permit or deny the operation.
16#[async_trait]
17pub trait PluginSecurityGate: Send + Sync {
18    /// Check whether the plugin is allowed to make an HTTP request.
19    async fn check_http_request(
20        &self,
21        plugin_id: &str,
22        method: &str,
23        url: &str,
24    ) -> Result<(), String>;
25
26    /// Check whether the plugin is allowed to read a file.
27    async fn check_file_read(&self, plugin_id: &str, path: &str) -> Result<(), String>;
28
29    /// Check whether the plugin is allowed to write a file.
30    async fn check_file_write(&self, plugin_id: &str, path: &str) -> Result<(), String>;
31}
32
33/// Security gate that permits all operations (for testing).
34#[derive(Debug, Clone, Copy, Default)]
35pub struct AllowAllGate;
36
37#[async_trait]
38impl PluginSecurityGate for AllowAllGate {
39    async fn check_http_request(
40        &self,
41        _plugin_id: &str,
42        _method: &str,
43        _url: &str,
44    ) -> Result<(), String> {
45        Ok(())
46    }
47
48    async fn check_file_read(&self, _plugin_id: &str, _path: &str) -> Result<(), String> {
49        Ok(())
50    }
51
52    async fn check_file_write(&self, _plugin_id: &str, _path: &str) -> Result<(), String> {
53        Ok(())
54    }
55}
56
57/// Security gate that denies all operations (for testing).
58#[derive(Debug, Clone, Copy, Default)]
59pub struct DenyAllGate;
60
61#[async_trait]
62impl PluginSecurityGate for DenyAllGate {
63    async fn check_http_request(
64        &self,
65        plugin_id: &str,
66        method: &str,
67        url: &str,
68    ) -> Result<(), String> {
69        Err(format!(
70            "plugin '{plugin_id}' denied: {method} {url} (DenyAllGate)"
71        ))
72    }
73
74    async fn check_file_read(&self, plugin_id: &str, path: &str) -> Result<(), String> {
75        Err(format!(
76            "plugin '{plugin_id}' denied: read {path} (DenyAllGate)"
77        ))
78    }
79
80    async fn check_file_write(&self, plugin_id: &str, path: &str) -> Result<(), String> {
81        Err(format!(
82            "plugin '{plugin_id}' denied: write {path} (DenyAllGate)"
83        ))
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Concrete adapter wrapping SecurityInterceptor (behind `approval` feature)
89// ---------------------------------------------------------------------------
90
91#[cfg(feature = "approval")]
92mod interceptor_gate {
93    use super::{PluginSecurityGate, async_trait};
94    use astrid_approval::action::SensitiveAction;
95    use astrid_approval::interceptor::SecurityInterceptor;
96    use astrid_core::types::Permission;
97    use std::sync::Arc;
98
99    /// Adapter that delegates security checks to [`SecurityInterceptor`].
100    ///
101    /// Creates the appropriate [`SensitiveAction`] variant for each operation
102    /// and calls `interceptor.intercept()`. A successful intercept means the
103    /// operation is allowed; an error means it is denied.
104    pub struct SecurityInterceptorGate {
105        interceptor: Arc<SecurityInterceptor>,
106    }
107
108    impl SecurityInterceptorGate {
109        /// Wrap a `SecurityInterceptor` in this gate.
110        #[must_use]
111        pub fn new(interceptor: Arc<SecurityInterceptor>) -> Self {
112            Self { interceptor }
113        }
114    }
115
116    impl std::fmt::Debug for SecurityInterceptorGate {
117        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118            f.debug_struct("SecurityInterceptorGate")
119                .finish_non_exhaustive()
120        }
121    }
122
123    #[async_trait]
124    impl PluginSecurityGate for SecurityInterceptorGate {
125        async fn check_http_request(
126            &self,
127            plugin_id: &str,
128            method: &str,
129            url: &str,
130        ) -> Result<(), String> {
131            let action = SensitiveAction::PluginHttpRequest {
132                plugin_id: plugin_id.to_string(),
133                url: url.to_string(),
134                method: method.to_string(),
135            };
136            self.interceptor
137                .intercept(&action, "plugin host function: HTTP request", None)
138                .await
139                .map(|_| ())
140                .map_err(|e| e.to_string())
141        }
142
143        async fn check_file_read(&self, plugin_id: &str, path: &str) -> Result<(), String> {
144            let action = SensitiveAction::PluginFileAccess {
145                plugin_id: plugin_id.to_string(),
146                path: path.to_string(),
147                mode: Permission::Read,
148            };
149            self.interceptor
150                .intercept(&action, "plugin host function: file read", None)
151                .await
152                .map(|_| ())
153                .map_err(|e| e.to_string())
154        }
155
156        async fn check_file_write(&self, plugin_id: &str, path: &str) -> Result<(), String> {
157            let action = SensitiveAction::PluginFileAccess {
158                plugin_id: plugin_id.to_string(),
159                path: path.to_string(),
160                mode: Permission::Write,
161            };
162            self.interceptor
163                .intercept(&action, "plugin host function: file write", None)
164                .await
165                .map(|_| ())
166                .map_err(|e| e.to_string())
167        }
168    }
169}
170
171#[cfg(feature = "approval")]
172pub use interceptor_gate::SecurityInterceptorGate;
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[tokio::test]
179    async fn allow_all_gate_permits_everything() {
180        let gate = AllowAllGate;
181        assert!(
182            gate.check_http_request("p", "GET", "http://x")
183                .await
184                .is_ok()
185        );
186        assert!(gate.check_file_read("p", "/tmp/f").await.is_ok());
187        assert!(gate.check_file_write("p", "/tmp/f").await.is_ok());
188    }
189
190    #[tokio::test]
191    async fn deny_all_gate_rejects_everything() {
192        let gate = DenyAllGate;
193        assert!(
194            gate.check_http_request("p", "GET", "http://x")
195                .await
196                .is_err()
197        );
198        assert!(gate.check_file_read("p", "/tmp/f").await.is_err());
199        assert!(gate.check_file_write("p", "/tmp/f").await.is_err());
200    }
201}