Skip to main content

codetether_agent/tool/
sandbox.rs

1//! Plugin signing, content verification, and subprocess policy checks.
2//!
3//! Plugin registration fails closed unless the manifest signature is valid and
4//! the declared content hash matches the bytes or file supplied by the caller.
5//! Subprocess execution enforces pre-spawn policy checks and reports any
6//! platform fallback that is only advisory.
7//!
8//! Built-in tools are trusted. Third-party plugins must be registered with
9//! verified content before callers should execute them.
10
11use 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/// Manifest describing a plugin tool.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PluginManifest {
23    /// Unique plugin identifier.
24    pub id: String,
25    /// Human-readable name.
26    pub name: String,
27    /// Semantic version.
28    pub version: String,
29    /// SHA-256 hash of the plugin content (source or binary).
30    pub content_hash: String,
31    /// Who signed this manifest.
32    pub signed_by: String,
33    /// Hex-encoded HMAC-SHA256 signature of `id|version|content_hash`.
34    pub signature: String,
35    /// Allowed capabilities (e.g., "fs:read", "net:connect", "exec:shell").
36    #[serde(default)]
37    pub capabilities: Vec<String>,
38    /// Maximum execution time in seconds.
39    #[serde(default = "default_timeout")]
40    pub timeout_secs: u64,
41}
42
43fn default_timeout() -> u64 {
44    30
45}
46
47/// Sandbox execution policy for a tool invocation.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SandboxPolicy {
50    /// Whether filesystem access is allowed (and to which paths).
51    pub allowed_paths: Vec<PathBuf>,
52    /// Whether network access is allowed.
53    pub allow_network: bool,
54    /// Whether shell execution is allowed.
55    pub allow_exec: bool,
56    /// Maximum execution time in seconds.
57    pub timeout_secs: u64,
58    /// Maximum memory in bytes (0 = no limit).
59    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/// Result of a sandboxed tool execution.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SandboxResult {
77    pub tool_id: String,
78    pub success: bool,
79    pub output: String,
80    /// SHA-256 hash of the combined output for integrity verification.
81    pub output_hash: String,
82    pub exit_code: Option<i32>,
83    pub duration_ms: u64,
84    pub sandbox_violations: Vec<String>,
85    /// Isolation gaps that are visible to callers instead of hidden.
86    #[serde(default)]
87    pub unsafe_fallbacks: Vec<String>,
88}
89
90/// The signing key used to verify plugin manifests.
91#[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    /// Load the HMAC key from `CODETETHER_PLUGIN_SIGNING_KEY` or generate one.
108    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    /// Load the process-wide signing key exactly once.
130    pub fn shared() -> Self {
131        GLOBAL_SIGNING_KEY.get_or_init(Self::from_env).clone()
132    }
133
134    /// Create with an explicit key (for tests).
135    #[cfg(test)]
136    pub fn with_key(key: Vec<u8>) -> Self {
137        Self { key: Arc::new(key) }
138    }
139
140    /// Sign a manifest payload: `id|version|content_hash`.
141    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    /// Verify a manifest signature.
153    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
159/// Compute SHA-256 hash of file contents.
160pub 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
177/// Compute SHA-256 hash of byte content.
178pub fn hash_bytes(data: &[u8]) -> String {
179    let mut hasher = Sha256::new();
180    hasher.update(data);
181    hex::encode(hasher.finalize())
182}
183
184/// Plugin registry — tracks registered and verified plugins.
185#[derive(Debug, Clone)]
186pub struct PluginRegistry {
187    signing_key: SigningKey,
188    /// Verified plugins: id -> manifest.
189    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    /// Register a plugin manifest only when unsafe content bypass is enabled.
205    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    /// Register a plugin after verifying its signature and byte content.
217    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    /// Register a plugin after verifying its signature and file content.
223    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    /// Check if a plugin is registered and verified.
274    pub async fn is_verified(&self, plugin_id: &str) -> bool {
275        self.plugins.read().await.contains_key(plugin_id)
276    }
277
278    /// Get a plugin manifest.
279    pub async fn get(&self, plugin_id: &str) -> Option<PluginManifest> {
280        self.plugins.read().await.get(plugin_id).cloned()
281    }
282
283    /// List all registered plugins.
284    pub async fn list(&self) -> Vec<PluginManifest> {
285        self.plugins.read().await.values().cloned().collect()
286    }
287
288    /// Get a reference to the signing key (for creating manifests).
289    pub fn signing_key(&self) -> &SigningKey {
290        &self.signing_key
291    }
292
293    /// Verify a plugin's content hash against a file on disk.
294    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
310/// Execute a tool in a sandboxed subprocess.
311pub 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    // Build restricted environment — strip everything except essentials.
329    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        // Keep the advisory marker visible for cooperative child processes.
338        // Non-cooperative known network commands are rejected before spawn.
339        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
417/// Constant-time byte comparison.
418fn 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(), // tampered version
477            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}