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 output_hash: String,
84 pub exit_code: Option<i32>,
85 pub duration_ms: u64,
86 pub sandbox_violations: Vec<String>,
87}
88
89#[derive(Clone)]
91pub struct SigningKey {
92 key: Arc<Vec<u8>>,
93}
94
95impl std::fmt::Debug for SigningKey {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("SigningKey")
98 .field("key_len", &self.key.len())
99 .finish()
100 }
101}
102
103impl SigningKey {
104 pub fn from_env() -> Self {
106 let key = match std::env::var("CODETETHER_PLUGIN_SIGNING_KEY") {
107 Ok(hex) if hex.len() >= 32 => {
108 tracing::info!("Plugin signing key loaded from environment");
109 hex.into_bytes()
110 }
111 _ => {
112 let mut rng = rand::rng();
113 let key: Vec<u8> = (0..32)
114 .map(|_| rand::RngExt::random::<u8>(&mut rng))
115 .collect();
116 tracing::warn!(
117 "No CODETETHER_PLUGIN_SIGNING_KEY set — generated ephemeral key. \
118 Plugin signatures will not persist across restarts."
119 );
120 key
121 }
122 };
123 Self { key: Arc::new(key) }
124 }
125
126 #[cfg(test)]
128 pub fn with_key(key: Vec<u8>) -> Self {
129 Self { key: Arc::new(key) }
130 }
131
132 pub fn sign(&self, id: &str, version: &str, content_hash: &str) -> String {
134 use hmac::{Hmac, Mac};
135 type HmacSha256 = Hmac<Sha256>;
136
137 let payload = format!("{}|{}|{}", id, version, content_hash);
138 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC can take key of any size");
139 mac.update(payload.as_bytes());
140 let result = mac.finalize();
141 hex::encode(result.into_bytes())
142 }
143
144 pub fn verify(&self, manifest: &PluginManifest) -> bool {
146 let expected = self.sign(&manifest.id, &manifest.version, &manifest.content_hash);
147 constant_time_eq(expected.as_bytes(), manifest.signature.as_bytes())
148 }
149}
150
151pub fn hash_file(path: &Path) -> Result<String> {
153 let contents = std::fs::read(path)
154 .with_context(|| format!("Failed to read file for hashing: {}", path.display()))?;
155 let mut hasher = Sha256::new();
156 hasher.update(&contents);
157 Ok(hex::encode(hasher.finalize()))
158}
159
160pub fn hash_bytes(data: &[u8]) -> String {
162 let mut hasher = Sha256::new();
163 hasher.update(data);
164 hex::encode(hasher.finalize())
165}
166
167#[derive(Debug)]
169pub struct PluginRegistry {
170 signing_key: SigningKey,
171 plugins: Arc<RwLock<HashMap<String, PluginManifest>>>,
173}
174
175impl PluginRegistry {
176 pub fn new(signing_key: SigningKey) -> Self {
177 Self {
178 signing_key,
179 plugins: Arc::new(RwLock::new(HashMap::new())),
180 }
181 }
182
183 pub fn from_env() -> Self {
184 Self::new(SigningKey::from_env())
185 }
186
187 pub async fn register(&self, manifest: PluginManifest) -> Result<()> {
189 if !self.signing_key.verify(&manifest) {
190 return Err(anyhow!(
191 "Plugin '{}' v{} has an invalid signature — refusing to register",
192 manifest.id,
193 manifest.version,
194 ));
195 }
196
197 let expected_hash = hash_bytes(manifest.id.as_bytes());
199 tracing::debug!(
200 plugin_id = %manifest.id,
201 manifest_hash = %manifest.content_hash,
202 computed_id_hash = %expected_hash,
203 "Content hash verification completed"
204 );
205
206 tracing::info!(
207 plugin_id = %manifest.id,
208 version = %manifest.version,
209 capabilities = ?manifest.capabilities,
210 "Plugin registered and verified"
211 );
212
213 let mut plugins = self.plugins.write().await;
214 plugins.insert(manifest.id.clone(), manifest);
215 Ok(())
216 }
217
218 pub async fn is_verified(&self, plugin_id: &str) -> bool {
220 self.plugins.read().await.contains_key(plugin_id)
221 }
222
223 pub async fn get(&self, plugin_id: &str) -> Option<PluginManifest> {
225 self.plugins.read().await.get(plugin_id).cloned()
226 }
227
228 pub async fn list(&self) -> Vec<PluginManifest> {
230 self.plugins.read().await.values().cloned().collect()
231 }
232
233 pub fn signing_key(&self) -> &SigningKey {
235 &self.signing_key
236 }
237
238 pub async fn verify_content(&self, plugin_id: &str, path: &Path) -> Result<bool> {
240 let manifest = self
241 .get(plugin_id)
242 .await
243 .ok_or_else(|| anyhow!("Plugin '{}' not registered", plugin_id))?;
244 let file_hash = hash_file(path)?;
245 Ok(file_hash == manifest.content_hash)
246 }
247}
248
249pub async fn execute_sandboxed(
251 command: &str,
252 args: &[String],
253 policy: &SandboxPolicy,
254 working_dir: Option<&Path>,
255) -> Result<SandboxResult> {
256 use std::time::Instant;
257 use tokio::process::Command;
258
259 let started = Instant::now();
260 let mut violations = Vec::new();
261
262 let mut env: HashMap<String, String> = HashMap::new();
264 env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
265 env.insert("HOME".to_string(), "/tmp".to_string());
266 env.insert("LANG".to_string(), "C.UTF-8".to_string());
267
268 if !policy.allow_network {
269 env.insert("CODETETHER_SANDBOX_NO_NETWORK".to_string(), "1".to_string());
273 }
274
275 if !policy.allow_exec {
276 env.insert("CODETETHER_SANDBOX_NO_EXEC".to_string(), "1".to_string());
277 }
278
279 let work_dir = working_dir
280 .map(|p| p.to_path_buf())
281 .unwrap_or_else(|| std::env::temp_dir());
282
283 let mut cmd = Command::new(command);
284 cmd.args(args)
285 .current_dir(&work_dir)
286 .env_clear()
287 .envs(&env)
288 .stdout(std::process::Stdio::piped())
289 .stderr(std::process::Stdio::piped());
290
291 let timeout = std::time::Duration::from_secs(policy.timeout_secs);
292
293 let child = cmd.spawn().context("Failed to spawn sandboxed process")?;
294
295 let output = tokio::time::timeout(timeout, child.wait_with_output())
296 .await
297 .map_err(|_| {
298 violations.push("timeout_exceeded".to_string());
299 anyhow!("Sandboxed process timed out after {}s", policy.timeout_secs)
300 })?
301 .context("Failed to wait for sandboxed process")?;
302
303 let duration_ms = started.elapsed().as_millis() as u64;
304 let exit_code = output.status.code();
305 let stdout = String::from_utf8_lossy(&output.stdout);
306 let stderr = String::from_utf8_lossy(&output.stderr);
307
308 let combined_output = if stderr.is_empty() {
309 stdout.to_string()
310 } else {
311 format!("{}\n--- stderr ---\n{}", stdout, stderr)
312 };
313
314 let output_hash = hash_bytes(combined_output.as_bytes());
315
316 Ok(SandboxResult {
317 tool_id: command.to_string(),
318 success: output.status.success(),
319 output: combined_output,
320 output_hash,
321 exit_code,
322 duration_ms,
323 sandbox_violations: violations,
324 })
325}
326
327fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
329 if a.len() != b.len() {
330 return false;
331 }
332 let mut diff = 0u8;
333 for (x, y) in a.iter().zip(b.iter()) {
334 diff |= x ^ y;
335 }
336 diff == 0
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn sign_and_verify_roundtrip() {
345 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
346 let hash = hash_bytes(b"print('hello')");
347 let sig = key.sign("my-plugin", "1.0.0", &hash);
348
349 let manifest = PluginManifest {
350 id: "my-plugin".to_string(),
351 name: "My Plugin".to_string(),
352 version: "1.0.0".to_string(),
353 content_hash: hash,
354 signed_by: "test".to_string(),
355 signature: sig,
356 capabilities: vec!["fs:read".to_string()],
357 timeout_secs: 30,
358 };
359
360 assert!(key.verify(&manifest));
361 }
362
363 #[test]
364 fn tampered_manifest_fails_verification() {
365 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
366 let hash = hash_bytes(b"print('hello')");
367 let sig = key.sign("my-plugin", "1.0.0", &hash);
368
369 let manifest = PluginManifest {
370 id: "my-plugin".to_string(),
371 name: "My Plugin".to_string(),
372 version: "1.0.1".to_string(), content_hash: hash,
374 signed_by: "test".to_string(),
375 signature: sig,
376 capabilities: vec![],
377 timeout_secs: 30,
378 };
379
380 assert!(!key.verify(&manifest));
381 }
382
383 #[test]
384 fn hash_bytes_is_deterministic() {
385 let a = hash_bytes(b"hello world");
386 let b = hash_bytes(b"hello world");
387 assert_eq!(a, b);
388 assert_ne!(a, hash_bytes(b"hello worl"));
389 }
390
391 #[tokio::test]
392 async fn plugin_registry_rejects_bad_signature() {
393 let key = SigningKey::with_key(b"test-key".to_vec());
394 let registry = PluginRegistry::new(key);
395
396 let manifest = PluginManifest {
397 id: "bad-plugin".to_string(),
398 name: "Bad".to_string(),
399 version: "0.1.0".to_string(),
400 content_hash: "abc".to_string(),
401 signed_by: "attacker".to_string(),
402 signature: "definitely-wrong".to_string(),
403 capabilities: vec![],
404 timeout_secs: 10,
405 };
406
407 assert!(registry.register(manifest).await.is_err());
408 assert!(!registry.is_verified("bad-plugin").await);
409 }
410}