1use anyhow::{Context, Result, anyhow};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio::sync::RwLock;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PluginManifest {
24 pub id: String,
26 pub name: String,
28 pub version: String,
30 pub content_hash: String,
32 pub signed_by: String,
34 pub signature: String,
37 #[serde(default)]
39 pub capabilities: Vec<String>,
40 #[serde(default = "default_timeout")]
42 pub timeout_secs: u64,
43}
44
45fn default_timeout() -> u64 {
46 30
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SandboxPolicy {
52 pub allowed_paths: Vec<PathBuf>,
54 pub allow_network: bool,
56 pub allow_exec: bool,
58 pub timeout_secs: u64,
60 pub max_memory_bytes: u64,
62}
63
64impl Default for SandboxPolicy {
65 fn default() -> Self {
66 Self {
67 allowed_paths: Vec::new(),
68 allow_network: false,
69 allow_exec: false,
70 timeout_secs: 30,
71 max_memory_bytes: 256 * 1024 * 1024, }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SandboxResult {
79 pub tool_id: String,
80 pub success: bool,
81 pub output: String,
82 pub exit_code: Option<i32>,
83 pub duration_ms: u64,
84 pub sandbox_violations: Vec<String>,
85}
86
87#[derive(Clone)]
89pub struct SigningKey {
90 key: Arc<Vec<u8>>,
91}
92
93impl std::fmt::Debug for SigningKey {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("SigningKey")
96 .field("key_len", &self.key.len())
97 .finish()
98 }
99}
100
101impl SigningKey {
102 pub fn from_env() -> Self {
104 let key = match std::env::var("CODETETHER_PLUGIN_SIGNING_KEY") {
105 Ok(hex) if hex.len() >= 32 => {
106 tracing::info!("Plugin signing key loaded from environment");
107 hex.into_bytes()
108 }
109 _ => {
110 let mut rng = rand::rng();
111 let key: Vec<u8> = (0..32).map(|_| rand::Rng::random::<u8>(&mut rng)).collect();
112 tracing::warn!(
113 "No CODETETHER_PLUGIN_SIGNING_KEY set — generated ephemeral key. \
114 Plugin signatures will not persist across restarts."
115 );
116 key
117 }
118 };
119 Self { key: Arc::new(key) }
120 }
121
122 #[cfg(test)]
124 pub fn with_key(key: Vec<u8>) -> Self {
125 Self { key: Arc::new(key) }
126 }
127
128 pub fn sign(&self, id: &str, version: &str, content_hash: &str) -> String {
130 use hmac::{Hmac, Mac};
131 type HmacSha256 = Hmac<Sha256>;
132
133 let payload = format!("{}|{}|{}", id, version, content_hash);
134 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC can take key of any size");
135 mac.update(payload.as_bytes());
136 let result = mac.finalize();
137 hex::encode(result.into_bytes())
138 }
139
140 pub fn verify(&self, manifest: &PluginManifest) -> bool {
142 let expected = self.sign(&manifest.id, &manifest.version, &manifest.content_hash);
143 constant_time_eq(expected.as_bytes(), manifest.signature.as_bytes())
144 }
145}
146
147pub fn hash_file(path: &Path) -> Result<String> {
149 let contents = std::fs::read(path)
150 .with_context(|| format!("Failed to read file for hashing: {}", path.display()))?;
151 let mut hasher = Sha256::new();
152 hasher.update(&contents);
153 Ok(hex::encode(hasher.finalize()))
154}
155
156pub fn hash_bytes(data: &[u8]) -> String {
158 let mut hasher = Sha256::new();
159 hasher.update(data);
160 hex::encode(hasher.finalize())
161}
162
163#[derive(Debug)]
165pub struct PluginRegistry {
166 signing_key: SigningKey,
167 plugins: Arc<RwLock<HashMap<String, PluginManifest>>>,
169}
170
171impl PluginRegistry {
172 pub fn new(signing_key: SigningKey) -> Self {
173 Self {
174 signing_key,
175 plugins: Arc::new(RwLock::new(HashMap::new())),
176 }
177 }
178
179 pub fn from_env() -> Self {
180 Self::new(SigningKey::from_env())
181 }
182
183 pub async fn register(&self, manifest: PluginManifest) -> Result<()> {
185 if !self.signing_key.verify(&manifest) {
186 return Err(anyhow!(
187 "Plugin '{}' v{} has an invalid signature — refusing to register",
188 manifest.id,
189 manifest.version,
190 ));
191 }
192
193 tracing::info!(
194 plugin_id = %manifest.id,
195 version = %manifest.version,
196 capabilities = ?manifest.capabilities,
197 "Plugin registered and verified"
198 );
199
200 let mut plugins = self.plugins.write().await;
201 plugins.insert(manifest.id.clone(), manifest);
202 Ok(())
203 }
204
205 pub async fn is_verified(&self, plugin_id: &str) -> bool {
207 self.plugins.read().await.contains_key(plugin_id)
208 }
209
210 pub async fn get(&self, plugin_id: &str) -> Option<PluginManifest> {
212 self.plugins.read().await.get(plugin_id).cloned()
213 }
214
215 pub async fn list(&self) -> Vec<PluginManifest> {
217 self.plugins.read().await.values().cloned().collect()
218 }
219
220 pub fn signing_key(&self) -> &SigningKey {
222 &self.signing_key
223 }
224}
225
226pub async fn execute_sandboxed(
228 command: &str,
229 args: &[String],
230 policy: &SandboxPolicy,
231 working_dir: Option<&Path>,
232) -> Result<SandboxResult> {
233 use std::time::Instant;
234 use tokio::process::Command;
235
236 let started = Instant::now();
237 let mut violations = Vec::new();
238
239 let mut env: HashMap<String, String> = HashMap::new();
241 env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
242 env.insert("HOME".to_string(), "/tmp".to_string());
243 env.insert("LANG".to_string(), "C.UTF-8".to_string());
244
245 if !policy.allow_network {
246 env.insert("CODETETHER_SANDBOX_NO_NETWORK".to_string(), "1".to_string());
250 }
251
252 if !policy.allow_exec {
253 env.insert("CODETETHER_SANDBOX_NO_EXEC".to_string(), "1".to_string());
254 }
255
256 let work_dir = working_dir
257 .map(|p| p.to_path_buf())
258 .unwrap_or_else(|| std::env::temp_dir());
259
260 let mut cmd = Command::new(command);
261 cmd.args(args)
262 .current_dir(&work_dir)
263 .env_clear()
264 .envs(&env)
265 .stdout(std::process::Stdio::piped())
266 .stderr(std::process::Stdio::piped());
267
268 let timeout = std::time::Duration::from_secs(policy.timeout_secs);
269
270 let child = cmd.spawn().context("Failed to spawn sandboxed process")?;
271
272 let output = tokio::time::timeout(timeout, child.wait_with_output())
273 .await
274 .map_err(|_| {
275 violations.push("timeout_exceeded".to_string());
276 anyhow!("Sandboxed process timed out after {}s", policy.timeout_secs)
277 })?
278 .context("Failed to wait for sandboxed process")?;
279
280 let duration_ms = started.elapsed().as_millis() as u64;
281 let exit_code = output.status.code();
282 let stdout = String::from_utf8_lossy(&output.stdout);
283 let stderr = String::from_utf8_lossy(&output.stderr);
284
285 let combined_output = if stderr.is_empty() {
286 stdout.to_string()
287 } else {
288 format!("{}\n--- stderr ---\n{}", stdout, stderr)
289 };
290
291 Ok(SandboxResult {
292 tool_id: command.to_string(),
293 success: output.status.success(),
294 output: combined_output,
295 exit_code,
296 duration_ms,
297 sandbox_violations: violations,
298 })
299}
300
301fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
303 if a.len() != b.len() {
304 return false;
305 }
306 let mut diff = 0u8;
307 for (x, y) in a.iter().zip(b.iter()) {
308 diff |= x ^ y;
309 }
310 diff == 0
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn sign_and_verify_roundtrip() {
319 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
320 let hash = hash_bytes(b"print('hello')");
321 let sig = key.sign("my-plugin", "1.0.0", &hash);
322
323 let manifest = PluginManifest {
324 id: "my-plugin".to_string(),
325 name: "My Plugin".to_string(),
326 version: "1.0.0".to_string(),
327 content_hash: hash,
328 signed_by: "test".to_string(),
329 signature: sig,
330 capabilities: vec!["fs:read".to_string()],
331 timeout_secs: 30,
332 };
333
334 assert!(key.verify(&manifest));
335 }
336
337 #[test]
338 fn tampered_manifest_fails_verification() {
339 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
340 let hash = hash_bytes(b"print('hello')");
341 let sig = key.sign("my-plugin", "1.0.0", &hash);
342
343 let manifest = PluginManifest {
344 id: "my-plugin".to_string(),
345 name: "My Plugin".to_string(),
346 version: "1.0.1".to_string(), content_hash: hash,
348 signed_by: "test".to_string(),
349 signature: sig,
350 capabilities: vec![],
351 timeout_secs: 30,
352 };
353
354 assert!(!key.verify(&manifest));
355 }
356
357 #[test]
358 fn hash_bytes_is_deterministic() {
359 let a = hash_bytes(b"hello world");
360 let b = hash_bytes(b"hello world");
361 assert_eq!(a, b);
362 assert_ne!(a, hash_bytes(b"hello worl"));
363 }
364
365 #[tokio::test]
366 async fn plugin_registry_rejects_bad_signature() {
367 let key = SigningKey::with_key(b"test-key".to_vec());
368 let registry = PluginRegistry::new(key);
369
370 let manifest = PluginManifest {
371 id: "bad-plugin".to_string(),
372 name: "Bad".to_string(),
373 version: "0.1.0".to_string(),
374 content_hash: "abc".to_string(),
375 signed_by: "attacker".to_string(),
376 signature: "definitely-wrong".to_string(),
377 capabilities: vec![],
378 timeout_secs: 10,
379 };
380
381 assert!(registry.register(manifest).await.is_err());
382 assert!(!registry.is_verified("bad-plugin").await);
383 }
384}