1use super::{sandbox_limits, sandbox_network, sandbox_paths};
12use anyhow::{Context, Result, anyhow};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, OnceLock};
18use tokio::sync::RwLock;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PluginManifest {
23 pub id: String,
25 pub name: String,
27 pub version: String,
29 pub content_hash: String,
31 pub signed_by: String,
33 pub signature: String,
35 #[serde(default)]
37 pub capabilities: Vec<String>,
38 #[serde(default = "default_timeout")]
40 pub timeout_secs: u64,
41}
42
43fn default_timeout() -> u64 {
44 30
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SandboxPolicy {
50 pub allowed_paths: Vec<PathBuf>,
52 pub allow_network: bool,
54 pub allow_exec: bool,
56 pub timeout_secs: u64,
58 pub max_memory_bytes: u64,
60}
61
62impl Default for SandboxPolicy {
63 fn default() -> Self {
64 Self {
65 allowed_paths: Vec::new(),
66 allow_network: false,
67 allow_exec: false,
68 timeout_secs: 30,
69 max_memory_bytes: 0,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SandboxResult {
77 pub tool_id: String,
78 pub success: bool,
79 pub output: String,
80 pub output_hash: String,
82 pub exit_code: Option<i32>,
83 pub duration_ms: u64,
84 pub sandbox_violations: Vec<String>,
85 #[serde(default)]
87 pub unsafe_fallbacks: Vec<String>,
88}
89
90#[derive(Clone)]
92pub struct SigningKey {
93 key: Arc<Vec<u8>>,
94}
95
96static GLOBAL_SIGNING_KEY: OnceLock<SigningKey> = OnceLock::new();
97
98impl std::fmt::Debug for SigningKey {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("SigningKey")
101 .field("key_len", &self.key.len())
102 .finish()
103 }
104}
105
106impl SigningKey {
107 pub fn from_env() -> Self {
109 let key = match std::env::var("CODETETHER_PLUGIN_SIGNING_KEY") {
110 Ok(hex) if hex.len() >= 32 => {
111 tracing::info!("Plugin signing key loaded from environment");
112 hex.into_bytes()
113 }
114 _ => {
115 let mut rng = rand::rng();
116 let key: Vec<u8> = (0..32)
117 .map(|_| rand::RngExt::random::<u8>(&mut rng))
118 .collect();
119 tracing::warn!(
120 "No CODETETHER_PLUGIN_SIGNING_KEY set — generated ephemeral key. \
121 Plugin signatures will not persist across restarts."
122 );
123 key
124 }
125 };
126 Self { key: Arc::new(key) }
127 }
128
129 pub fn shared() -> Self {
131 GLOBAL_SIGNING_KEY.get_or_init(Self::from_env).clone()
132 }
133
134 #[cfg(test)]
136 pub fn with_key(key: Vec<u8>) -> Self {
137 Self { key: Arc::new(key) }
138 }
139
140 pub fn sign(&self, id: &str, version: &str, content_hash: &str) -> String {
142 use hmac::{Hmac, Mac};
143 type HmacSha256 = Hmac<Sha256>;
144
145 let payload = format!("{}|{}|{}", id, version, content_hash);
146 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC can take key of any size");
147 mac.update(payload.as_bytes());
148 let result = mac.finalize();
149 hex::encode(result.into_bytes())
150 }
151
152 pub fn verify(&self, manifest: &PluginManifest) -> bool {
154 let expected = self.sign(&manifest.id, &manifest.version, &manifest.content_hash);
155 constant_time_eq(expected.as_bytes(), manifest.signature.as_bytes())
156 }
157}
158
159pub fn hash_file(path: &Path) -> Result<String> {
161 let contents = std::fs::read(path)
162 .with_context(|| format!("Failed to read file for hashing: {}", path.display()))?;
163 let mut hasher = Sha256::new();
164 hasher.update(&contents);
165 Ok(hex::encode(hasher.finalize()))
166}
167
168async fn hash_file_async(path: &Path) -> Result<String> {
169 let contents = tokio::fs::read(path)
170 .await
171 .with_context(|| format!("Failed to read file for hashing: {}", path.display()))?;
172 let mut hasher = Sha256::new();
173 hasher.update(&contents);
174 Ok(hex::encode(hasher.finalize()))
175}
176
177pub fn hash_bytes(data: &[u8]) -> String {
179 let mut hasher = Sha256::new();
180 hasher.update(data);
181 hex::encode(hasher.finalize())
182}
183
184#[derive(Debug, Clone)]
186pub struct PluginRegistry {
187 signing_key: SigningKey,
188 plugins: Arc<RwLock<HashMap<String, PluginManifest>>>,
190}
191
192impl PluginRegistry {
193 pub fn new(signing_key: SigningKey) -> Self {
194 Self {
195 signing_key,
196 plugins: Arc::new(RwLock::new(HashMap::new())),
197 }
198 }
199
200 pub fn from_env() -> Self {
201 Self::new(SigningKey::shared())
202 }
203
204 pub async fn register(&self, manifest: PluginManifest) -> Result<()> {
206 if !allow_unverified_content_registration() {
207 return Err(anyhow!(
208 "Plugin '{}' v{} requires content verification; use register_bytes or register_file",
209 manifest.id,
210 manifest.version,
211 ));
212 }
213 self.register_checked(manifest, None).await
214 }
215
216 pub async fn register_bytes(&self, manifest: PluginManifest, content: &[u8]) -> Result<()> {
218 self.register_checked(manifest, Some(hash_bytes(content)))
219 .await
220 }
221
222 pub async fn register_file(&self, manifest: PluginManifest, path: &Path) -> Result<()> {
224 self.register_checked(manifest, Some(hash_file_async(path).await?))
225 .await
226 }
227
228 async fn register_checked(
229 &self,
230 manifest: PluginManifest,
231 actual_hash: Option<String>,
232 ) -> Result<()> {
233 if !self.signing_key.verify(&manifest) {
234 return Err(anyhow!(
235 "Plugin '{}' v{} has an invalid signature — refusing to register",
236 manifest.id,
237 manifest.version,
238 ));
239 }
240
241 if let Some(actual_hash) = actual_hash {
242 if actual_hash != manifest.content_hash {
243 return Err(anyhow!(
244 "Plugin '{}' v{} content hash mismatch — refusing to register",
245 manifest.id,
246 manifest.version,
247 ));
248 }
249 tracing::debug!(
250 plugin_id = %manifest.id,
251 manifest_hash = %manifest.content_hash,
252 "Content hash verification completed"
253 );
254 } else {
255 tracing::warn!(
256 plugin_id = %manifest.id,
257 "Plugin registered through explicit unsafe content verification bypass"
258 );
259 }
260
261 tracing::info!(
262 plugin_id = %manifest.id,
263 version = %manifest.version,
264 capabilities = ?manifest.capabilities,
265 "Plugin registered and verified"
266 );
267
268 let mut plugins = self.plugins.write().await;
269 plugins.insert(manifest.id.clone(), manifest);
270 Ok(())
271 }
272
273 pub async fn is_verified(&self, plugin_id: &str) -> bool {
275 self.plugins.read().await.contains_key(plugin_id)
276 }
277
278 pub async fn get(&self, plugin_id: &str) -> Option<PluginManifest> {
280 self.plugins.read().await.get(plugin_id).cloned()
281 }
282
283 pub async fn list(&self) -> Vec<PluginManifest> {
285 self.plugins.read().await.values().cloned().collect()
286 }
287
288 pub fn signing_key(&self) -> &SigningKey {
290 &self.signing_key
291 }
292
293 pub async fn verify_content(&self, plugin_id: &str, path: &Path) -> Result<bool> {
295 let manifest = self
296 .get(plugin_id)
297 .await
298 .ok_or_else(|| anyhow!("Plugin '{}' not registered", plugin_id))?;
299 let file_hash = hash_file_async(path).await?;
300 Ok(file_hash == manifest.content_hash)
301 }
302}
303
304fn allow_unverified_content_registration() -> bool {
305 std::env::var("CODETETHER_ALLOW_UNVERIFIED_PLUGIN_CONTENT")
306 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
307 .unwrap_or(false)
308}
309
310pub async fn execute_sandboxed(
312 command: &str,
313 args: &[String],
314 policy: &SandboxPolicy,
315 working_dir: Option<&Path>,
316) -> Result<SandboxResult> {
317 use std::time::Instant;
318 use tokio::process::Command;
319
320 let started = Instant::now();
321 let mut violations = Vec::new();
322 let mut unsafe_fallbacks = Vec::new();
323
324 if !policy.allow_exec {
325 return Err(anyhow!("Sandbox policy denies process execution"));
326 }
327
328 let mut env: HashMap<String, String> = HashMap::new();
330 env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
331 env.insert("HOME".to_string(), "/tmp".to_string());
332 env.insert("LANG".to_string(), "C.UTF-8".to_string());
333 inject_codetether_runtime_env(&mut env);
334
335 unsafe_fallbacks.extend(sandbox_network::validate(policy, command, args)?);
336 if !policy.allow_network {
337 env.insert("CODETETHER_SANDBOX_NO_NETWORK".to_string(), "1".to_string());
340 }
341
342 let work_dir = working_dir
343 .map(|p| p.to_path_buf())
344 .unwrap_or_else(std::env::temp_dir);
345 sandbox_paths::validate_working_dir(policy, &work_dir).await?;
346
347 let mut cmd = Command::new(command);
348 cmd.args(args).current_dir(&work_dir).env_clear().envs(&env);
349 super::bash_noninteractive::configure(&mut cmd);
350 unsafe_fallbacks.extend(sandbox_limits::apply_memory_limit(
351 &mut cmd,
352 policy.max_memory_bytes,
353 ));
354
355 let timeout = std::time::Duration::from_secs(policy.timeout_secs);
356
357 let child = cmd.spawn().context("Failed to spawn sandboxed process")?;
358
359 let output = tokio::time::timeout(timeout, child.wait_with_output())
360 .await
361 .map_err(|_| {
362 violations.push("timeout_exceeded".to_string());
363 anyhow!("Sandboxed process timed out after {}s", policy.timeout_secs)
364 })?
365 .context("Failed to wait for sandboxed process")?;
366
367 let duration_ms = started.elapsed().as_millis() as u64;
368 let exit_code = output.status.code();
369 let stdout = String::from_utf8_lossy(&output.stdout);
370 let stderr = String::from_utf8_lossy(&output.stderr);
371
372 let combined_output = if stderr.is_empty() {
373 stdout.to_string()
374 } else {
375 format!("{}\n--- stderr ---\n{}", stdout, stderr)
376 };
377
378 let output_hash = hash_bytes(combined_output.as_bytes());
379
380 Ok(SandboxResult {
381 tool_id: command.to_string(),
382 success: output.status.success(),
383 output: combined_output,
384 output_hash,
385 exit_code,
386 duration_ms,
387 sandbox_violations: violations,
388 unsafe_fallbacks,
389 })
390}
391
392fn inject_codetether_runtime_env(env: &mut HashMap<String, String>) {
393 let Ok(current_exe) = std::env::current_exe() else {
394 return;
395 };
396 env.insert(
397 "CODETETHER_BIN".to_string(),
398 current_exe.to_string_lossy().into_owned(),
399 );
400
401 let mut path_entries = current_exe
402 .parent()
403 .map(|parent| vec![parent.to_path_buf()])
404 .unwrap_or_default();
405 if let Some(existing_path) = env
406 .get("PATH")
407 .map(std::ffi::OsString::from)
408 .or_else(|| std::env::var_os("PATH"))
409 {
410 path_entries.extend(std::env::split_paths(&existing_path));
411 }
412 if let Ok(path) = std::env::join_paths(path_entries) {
413 env.insert("PATH".to_string(), path.to_string_lossy().into_owned());
414 }
415}
416
417fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
419 if a.len() != b.len() {
420 return false;
421 }
422 let mut diff = 0u8;
423 for (x, y) in a.iter().zip(b.iter()) {
424 diff |= x ^ y;
425 }
426 diff == 0
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn signed_manifest(key: &SigningKey, id: &str, content: &[u8]) -> PluginManifest {
434 let hash = hash_bytes(content);
435 PluginManifest {
436 id: id.to_string(),
437 name: id.to_string(),
438 version: "1.0.0".to_string(),
439 content_hash: hash.clone(),
440 signed_by: "test".to_string(),
441 signature: key.sign(id, "1.0.0", &hash),
442 capabilities: vec!["fs:read".to_string()],
443 timeout_secs: 30,
444 }
445 }
446
447 #[test]
448 fn sign_and_verify_roundtrip() {
449 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
450 let hash = hash_bytes(b"print('hello')");
451 let sig = key.sign("my-plugin", "1.0.0", &hash);
452
453 let manifest = PluginManifest {
454 id: "my-plugin".to_string(),
455 name: "My Plugin".to_string(),
456 version: "1.0.0".to_string(),
457 content_hash: hash,
458 signed_by: "test".to_string(),
459 signature: sig,
460 capabilities: vec!["fs:read".to_string()],
461 timeout_secs: 30,
462 };
463
464 assert!(key.verify(&manifest));
465 }
466
467 #[test]
468 fn tampered_manifest_fails_verification() {
469 let key = SigningKey::with_key(b"test-secret-key-for-signing".to_vec());
470 let hash = hash_bytes(b"print('hello')");
471 let sig = key.sign("my-plugin", "1.0.0", &hash);
472
473 let manifest = PluginManifest {
474 id: "my-plugin".to_string(),
475 name: "My Plugin".to_string(),
476 version: "1.0.1".to_string(), content_hash: hash,
478 signed_by: "test".to_string(),
479 signature: sig,
480 capabilities: vec![],
481 timeout_secs: 30,
482 };
483
484 assert!(!key.verify(&manifest));
485 }
486
487 #[test]
488 fn hash_bytes_is_deterministic() {
489 let a = hash_bytes(b"hello world");
490 let b = hash_bytes(b"hello world");
491 assert_eq!(a, b);
492 assert_ne!(a, hash_bytes(b"hello worl"));
493 }
494
495 #[tokio::test]
496 async fn plugin_registry_rejects_bad_signature() {
497 let key = SigningKey::with_key(b"test-key".to_vec());
498 let registry = PluginRegistry::new(key);
499
500 let manifest = PluginManifest {
501 id: "bad-plugin".to_string(),
502 name: "Bad".to_string(),
503 version: "0.1.0".to_string(),
504 content_hash: "abc".to_string(),
505 signed_by: "attacker".to_string(),
506 signature: "definitely-wrong".to_string(),
507 capabilities: vec![],
508 timeout_secs: 10,
509 };
510
511 assert!(registry.register_bytes(manifest, b"content").await.is_err());
512 assert!(!registry.is_verified("bad-plugin").await);
513 }
514
515 #[tokio::test]
516 async fn plugin_registry_registers_matching_content() {
517 let key = SigningKey::with_key(b"test-key".to_vec());
518 let manifest = signed_manifest(&key, "good-plugin", b"content");
519 let registry = PluginRegistry::new(key);
520
521 registry
522 .register_bytes(manifest, b"content")
523 .await
524 .expect("matching content registers");
525 assert!(registry.is_verified("good-plugin").await);
526 }
527
528 #[tokio::test]
529 async fn plugin_registry_rejects_wrong_content_hash() {
530 let key = SigningKey::with_key(b"test-key".to_vec());
531 let manifest = signed_manifest(&key, "mismatch-plugin", b"content");
532 let registry = PluginRegistry::new(key);
533
534 assert!(
535 registry
536 .register_bytes(manifest, b"tampered")
537 .await
538 .is_err()
539 );
540 assert!(!registry.is_verified("mismatch-plugin").await);
541 }
542
543 #[tokio::test]
544 async fn plugin_registry_requires_content_by_default() {
545 let key = SigningKey::with_key(b"test-key".to_vec());
546 let manifest = signed_manifest(&key, "needs-content", b"content");
547 let registry = PluginRegistry::new(key);
548
549 assert!(registry.register(manifest).await.is_err());
550 }
551
552 #[tokio::test]
553 async fn sandbox_denies_working_dir_outside_allowed_paths() {
554 let allowed = tempfile::tempdir().expect("allowed tempdir");
555 let denied = tempfile::tempdir().expect("denied tempdir");
556 let policy = SandboxPolicy {
557 allowed_paths: vec![allowed.path().to_path_buf()],
558 allow_network: true,
559 allow_exec: true,
560 ..SandboxPolicy::default()
561 };
562
563 let err = execute_sandboxed(
564 "sh",
565 &["-c".to_string(), "echo denied".to_string()],
566 &policy,
567 Some(denied.path()),
568 )
569 .await
570 .expect_err("denied path should fail closed");
571 assert!(err.to_string().contains("denied working directory"));
572 }
573
574 #[test]
575 fn sandbox_memory_limit_is_opt_in_by_default() {
576 assert_eq!(SandboxPolicy::default().max_memory_bytes, 0);
577 }
578
579 #[tokio::test]
580 async fn sandbox_denies_network_commands_when_network_disabled() {
581 let policy = SandboxPolicy {
582 allow_network: false,
583 allow_exec: true,
584 ..SandboxPolicy::default()
585 };
586 let err = execute_sandboxed(
587 "sh",
588 &["-c".to_string(), "curl https://example.com".to_string()],
589 &policy,
590 None,
591 )
592 .await
593 .expect_err("network command should fail closed");
594 assert!(err.to_string().contains("denies network access"));
595 }
596
597 #[tokio::test]
598 async fn sandbox_denies_dequoted_network_commands() {
599 let policy = SandboxPolicy {
600 allow_network: false,
601 allow_exec: true,
602 ..SandboxPolicy::default()
603 };
604 let err = execute_sandboxed(
605 "sh",
606 &["-c".to_string(), "c\"\"url https://example.com".to_string()],
607 &policy,
608 None,
609 )
610 .await
611 .expect_err("dequoted network command should fail closed");
612 assert!(err.to_string().contains("denies network access"));
613 }
614}