Skip to main content

cortex_sdk/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::path::{Component, Path};
4
5use serde::{Deserialize, Serialize};
6
7pub const ABI_VERSION: u32 = 2;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct PluginContext {
11    pub tenant_id: String,
12    pub actor_id: String,
13    pub session_id: String,
14    pub capabilities: Vec<String>,
15    pub limits: ResourceLimits,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ResourceLimits {
20    pub timeout_ms: u64,
21    pub max_output_bytes: usize,
22    pub max_memory_bytes: usize,
23    pub allow_host_paths: bool,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ToolRequest {
28    pub name: String,
29    pub input: serde_json::Value,
30    pub required_capabilities: Vec<String>,
31    pub host_paths: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ToolResponse {
36    pub output: serde_json::Value,
37    pub audit_label: String,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum PluginBoundary {
43    Process,
44    Native,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct PluginManifest {
49    pub name: String,
50    pub version: String,
51    pub abi_version: u32,
52    pub boundary: PluginBoundary,
53    pub capabilities: Vec<String>,
54    pub limits: ResourceLimits,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum PluginAuthorizationError {
60    AbiMismatch { expected: u32, actual: u32 },
61    CapabilityNotDeclared { capability: String },
62    EmptyManifestField { field: &'static str },
63    HostPathDenied { path: String },
64    MissingCapability { capability: String },
65    OutputTooLarge { actual: usize, limit: usize },
66}
67
68impl ResourceLimits {
69    #[must_use]
70    pub const fn strict() -> Self {
71        Self {
72            timeout_ms: 5_000,
73            max_output_bytes: 64 * 1024,
74            max_memory_bytes: 64 * 1024 * 1024,
75            allow_host_paths: false,
76        }
77    }
78}
79
80impl PluginContext {
81    #[must_use]
82    pub fn has_capability(&self, capability: &str) -> bool {
83        self.capabilities
84            .iter()
85            .any(|candidate| candidate == capability)
86    }
87
88    /// # Errors
89    /// Returns an error when a request asks for capabilities or host paths not
90    /// granted by this context.
91    pub fn authorize(&self, request: &ToolRequest) -> Result<(), PluginAuthorizationError> {
92        for capability in &request.required_capabilities {
93            if !self.has_capability(capability) {
94                return Err(PluginAuthorizationError::MissingCapability {
95                    capability: capability.clone(),
96                });
97            }
98        }
99        if !self.limits.allow_host_paths {
100            for path in &request.host_paths {
101                if is_host_path(path) {
102                    return Err(PluginAuthorizationError::HostPathDenied { path: path.clone() });
103                }
104            }
105        }
106        Ok(())
107    }
108}
109
110impl ToolRequest {
111    #[must_use]
112    pub fn new(name: impl Into<String>, input: serde_json::Value) -> Self {
113        Self {
114            name: name.into(),
115            input,
116            required_capabilities: Vec::new(),
117            host_paths: Vec::new(),
118        }
119    }
120
121    #[must_use]
122    pub fn require_capability(mut self, capability: impl Into<String>) -> Self {
123        self.required_capabilities.push(capability.into());
124        self
125    }
126
127    #[must_use]
128    pub fn with_host_path(mut self, path: impl Into<String>) -> Self {
129        self.host_paths.push(path.into());
130        self
131    }
132}
133
134impl ToolResponse {
135    /// # Errors
136    /// Returns an error when the response exceeds the host output limit.
137    pub fn validate_output(&self, limits: ResourceLimits) -> Result<(), PluginAuthorizationError> {
138        let actual = self.output.to_string().len() + self.audit_label.len();
139        if actual > limits.max_output_bytes {
140            Err(PluginAuthorizationError::OutputTooLarge {
141                actual,
142                limit: limits.max_output_bytes,
143            })
144        } else {
145            Ok(())
146        }
147    }
148}
149
150impl PluginManifest {
151    #[must_use]
152    pub fn process(name: impl Into<String>, version: impl Into<String>) -> Self {
153        Self {
154            name: name.into(),
155            version: version.into(),
156            abi_version: ABI_VERSION,
157            boundary: PluginBoundary::Process,
158            capabilities: Vec::new(),
159            limits: ResourceLimits::strict(),
160        }
161    }
162
163    #[must_use]
164    pub fn with_capability(mut self, capability: impl Into<String>) -> Self {
165        self.capabilities.push(capability.into());
166        self.capabilities.sort();
167        self.capabilities.dedup();
168        self
169    }
170
171    #[must_use]
172    pub const fn with_limits(mut self, limits: ResourceLimits) -> Self {
173        self.limits = limits;
174        self
175    }
176
177    /// # Errors
178    /// Returns an error when the manifest is empty or targets another ABI.
179    pub const fn validate(&self) -> Result<(), PluginAuthorizationError> {
180        if self.name.is_empty() {
181            return Err(PluginAuthorizationError::EmptyManifestField { field: "name" });
182        }
183        if self.version.is_empty() {
184            return Err(PluginAuthorizationError::EmptyManifestField { field: "version" });
185        }
186        if self.abi_version != ABI_VERSION {
187            return Err(PluginAuthorizationError::AbiMismatch {
188                expected: ABI_VERSION,
189                actual: self.abi_version,
190            });
191        }
192        Ok(())
193    }
194
195    /// # Errors
196    /// Returns an error when the manifest is invalid or a request needs a
197    /// capability not declared by the plugin.
198    pub fn validate_request(&self, request: &ToolRequest) -> Result<(), PluginAuthorizationError> {
199        self.validate()?;
200        for capability in &request.required_capabilities {
201            if !self
202                .capabilities
203                .iter()
204                .any(|declared| declared == capability)
205            {
206                return Err(PluginAuthorizationError::CapabilityNotDeclared {
207                    capability: capability.clone(),
208                });
209            }
210        }
211        Ok(())
212    }
213}
214
215fn is_host_path(path: &str) -> bool {
216    let path = Path::new(path);
217    path.is_absolute()
218        || path
219            .components()
220            .any(|component| matches!(component, Component::ParentDir))
221}