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