use crate::manifest::CapsuleManifest;
use async_trait::async_trait;
#[async_trait]
pub trait CapsuleSecurityGate: Send + Sync {
async fn check_http_request(
&self,
capsule_id: &str,
method: &str,
url: &str,
) -> Result<(), String>;
async fn check_file_read(&self, capsule_id: &str, path: &str) -> Result<(), String>;
async fn check_file_write(&self, capsule_id: &str, path: &str) -> Result<(), String>;
async fn check_connector_register(
&self,
_capsule_id: &str,
_connector_name: &str,
_platform: &str,
) -> Result<(), String> {
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AllowAllGate;
#[async_trait]
impl CapsuleSecurityGate for AllowAllGate {
async fn check_http_request(
&self,
_capsule_id: &str,
_method: &str,
_url: &str,
) -> Result<(), String> {
Ok(())
}
async fn check_file_read(&self, _capsule_id: &str, _path: &str) -> Result<(), String> {
Ok(())
}
async fn check_file_write(&self, _capsule_id: &str, _path: &str) -> Result<(), String> {
Ok(())
}
async fn check_connector_register(
&self,
_capsule_id: &str,
_connector_name: &str,
_platform: &str,
) -> Result<(), String> {
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DenyAllGate;
#[async_trait]
impl CapsuleSecurityGate for DenyAllGate {
async fn check_http_request(
&self,
capsule_id: &str,
method: &str,
url: &str,
) -> Result<(), String> {
Err(format!(
"plugin '{capsule_id}' denied: {method} {url} (DenyAllGate)"
))
}
async fn check_file_read(&self, capsule_id: &str, path: &str) -> Result<(), String> {
Err(format!(
"plugin '{capsule_id}' denied: read {path} (DenyAllGate)"
))
}
async fn check_file_write(&self, capsule_id: &str, path: &str) -> Result<(), String> {
Err(format!(
"plugin '{capsule_id}' denied: write {path} (DenyAllGate)"
))
}
async fn check_connector_register(
&self,
capsule_id: &str,
connector_name: &str,
platform: &str,
) -> Result<(), String> {
Err(format!(
"plugin '{capsule_id}' denied: register connector {connector_name} ({platform}) (DenyAllGate)"
))
}
}
#[derive(Debug, Clone)]
pub struct ManifestSecurityGate {
manifest: CapsuleManifest,
}
impl ManifestSecurityGate {
pub fn new(manifest: CapsuleManifest) -> Self {
Self { manifest }
}
}
#[async_trait]
impl CapsuleSecurityGate for ManifestSecurityGate {
async fn check_http_request(
&self,
capsule_id: &str,
_method: &str,
url: &str,
) -> Result<(), String> {
let parsed_url = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL: {e}"))?;
let host_str = parsed_url.host_str().unwrap_or("");
if self
.manifest
.capabilities
.net
.iter()
.any(|d| d == "*" || host_str == d || host_str.ends_with(&format!(".{d}")))
{
Ok(())
} else {
Err(format!(
"plugin '{capsule_id}' denied: network access to '{url}' not declared in manifest"
))
}
}
async fn check_file_read(&self, capsule_id: &str, path: &str) -> Result<(), String> {
let path_obj = std::path::Path::new(path);
if self
.manifest
.capabilities
.fs_read
.iter()
.any(|p| p == "*" || path_obj.starts_with(p))
{
Ok(())
} else {
Err(format!(
"plugin '{capsule_id}' denied: read access to '{path}' not declared in manifest"
))
}
}
async fn check_file_write(&self, capsule_id: &str, path: &str) -> Result<(), String> {
let path_obj = std::path::Path::new(path);
if self
.manifest
.capabilities
.fs_write
.iter()
.any(|p| p == "*" || path_obj.starts_with(p))
{
Ok(())
} else {
Err(format!(
"plugin '{capsule_id}' denied: write access to '{path}' not declared in manifest"
))
}
}
}
#[cfg(feature = "approval")]
mod interceptor_gate {
use super::{CapsuleSecurityGate, async_trait};
use astrid_approval::action::SensitiveAction;
use astrid_approval::interceptor::SecurityInterceptor;
use astrid_core::types::Permission;
use std::sync::Arc;
pub struct SecurityInterceptorGate {
interceptor: Arc<SecurityInterceptor>,
}
impl SecurityInterceptorGate {
#[must_use]
pub fn new(interceptor: Arc<SecurityInterceptor>) -> Self {
Self { interceptor }
}
}
impl std::fmt::Debug for SecurityInterceptorGate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecurityInterceptorGate")
.finish_non_exhaustive()
}
}
#[async_trait]
impl CapsuleSecurityGate for SecurityInterceptorGate {
async fn check_http_request(
&self,
capsule_id: &str,
method: &str,
url: &str,
) -> Result<(), String> {
let action = SensitiveAction::CapsuleHttpRequest {
capsule_id: capsule_id.to_string(),
url: url.to_string(),
method: method.to_string(),
};
self.interceptor
.intercept(&action, "plugin host function: HTTP request", None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn check_file_read(&self, capsule_id: &str, path: &str) -> Result<(), String> {
let action = SensitiveAction::CapsuleFileAccess {
capsule_id: capsule_id.to_string(),
path: path.to_string(),
mode: Permission::Read,
};
self.interceptor
.intercept(&action, "plugin host function: file read", None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn check_file_write(&self, capsule_id: &str, path: &str) -> Result<(), String> {
let action = SensitiveAction::CapsuleFileAccess {
capsule_id: capsule_id.to_string(),
path: path.to_string(),
mode: Permission::Write,
};
self.interceptor
.intercept(&action, "plugin host function: file write", None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn check_connector_register(
&self,
capsule_id: &str,
connector_name: &str,
platform: &str,
) -> Result<(), String> {
let action = SensitiveAction::CapsuleExecution {
capsule_id: capsule_id.to_string(),
capability: format!("register_connector({connector_name}, {platform})"),
};
self.interceptor
.intercept(&action, "plugin host function: register connector", None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
}
}
#[cfg(feature = "approval")]
pub use interceptor_gate::SecurityInterceptorGate;
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{CapabilitiesDef, CapsuleManifest, PackageDef};
fn make_manifest(net: Vec<&str>, fs_read: Vec<&str>, fs_write: Vec<&str>) -> CapsuleManifest {
CapsuleManifest {
package: PackageDef {
name: "test".into(),
version: "0.1.0".into(),
description: None,
authors: vec![],
repository: None,
homepage: None,
documentation: None,
license: None,
license_file: None,
readme: None,
keywords: vec![],
categories: vec![],
astrid_version: None,
publish: None,
include: None,
exclude: None,
metadata: None,
},
component: None,
dependencies: Default::default(),
capabilities: CapabilitiesDef {
net: net.into_iter().map(String::from).collect(),
kv: vec![],
fs_read: fs_read.into_iter().map(String::from).collect(),
fs_write: fs_write.into_iter().map(String::from).collect(),
host_process: vec![],
},
env: Default::default(),
context_files: vec![],
commands: vec![],
mcp_servers: vec![],
skills: vec![],
uplinks: vec![],
llm_providers: vec![],
interceptors: vec![],
cron_jobs: vec![],
tools: vec![],
}
}
#[tokio::test]
async fn test_manifest_security_gate_http() {
let manifest = make_manifest(vec!["api.github.com"], vec![], vec![]);
let gate = ManifestSecurityGate::new(manifest);
assert!(
gate.check_http_request("test", "GET", "https://api.github.com/v1")
.await
.is_ok()
);
assert!(
gate.check_http_request("test", "GET", "https://v1.api.github.com/v1")
.await
.is_ok()
);
assert!(
gate.check_http_request("test", "GET", "https://evil.com/v1")
.await
.is_err()
);
assert!(
gate.check_http_request("test", "GET", "http://api.github.com@127.0.0.1/admin")
.await
.is_err()
);
assert!(
gate.check_http_request("test", "GET", "http://github.com/v1")
.await
.is_err()
);
let all_manifest = make_manifest(vec!["*"], vec![], vec![]);
let all_gate = ManifestSecurityGate::new(all_manifest);
assert!(
all_gate
.check_http_request("test", "GET", "https://evil.com/v1")
.await
.is_ok()
);
}
#[tokio::test]
async fn test_manifest_security_gate_fs() {
let manifest = make_manifest(vec![], vec!["/workspace/src", "/tmp/exact.txt"], vec!["*"]);
let gate = ManifestSecurityGate::new(manifest);
assert!(
gate.check_file_read("test", "/workspace/src/main.rs")
.await
.is_ok()
);
assert!(gate.check_file_read("test", "/tmp/exact.txt").await.is_ok());
assert!(
gate.check_file_read("test", "/workspace/src-evil/main.rs")
.await
.is_err()
);
assert!(
gate.check_file_read("test", "/workspace/src_evil/main.rs")
.await
.is_err()
);
assert!(gate.check_file_read("test", "/workspace/src").await.is_ok());
assert!(gate.check_file_write("test", "/etc/passwd").await.is_ok());
assert!(
gate.check_file_write("test", "/random/file.txt")
.await
.is_ok()
);
}
#[tokio::test]
async fn allow_all_gate_permits_everything() {
let gate = AllowAllGate;
assert!(
gate.check_http_request("p", "GET", "http://x")
.await
.is_ok()
);
assert!(gate.check_file_read("p", "/tmp/f").await.is_ok());
assert!(gate.check_file_write("p", "/tmp/f").await.is_ok());
assert!(
gate.check_connector_register("p", "my-conn", "discord")
.await
.is_ok()
);
}
#[tokio::test]
async fn deny_all_gate_rejects_everything() {
let gate = DenyAllGate;
assert!(
gate.check_http_request("p", "GET", "http://x")
.await
.is_err()
);
assert!(gate.check_file_read("p", "/tmp/f").await.is_err());
assert!(gate.check_file_write("p", "/tmp/f").await.is_err());
assert!(
gate.check_connector_register("p", "my-conn", "discord")
.await
.is_err()
);
}
}