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 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 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 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 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}