Skip to main content

codetether_agent/tool/
sandbox.rs

1//! Plugin sandboxing and code-signing for tool execution.
2//!
3//! Every tool invocation is mediated through a sandbox that:
4//! 1. Validates the tool manifest signature before execution.
5//! 2. Runs external/plugin tools in an isolated subprocess with restricted
6//!    environment, working directory, and resource limits.
7//! 3. Records execution results in the audit trail.
8//!
9//! Built-in tools (those compiled into the binary) are trusted but still
10//! audit-logged.  Third-party plugin tools must have a valid manifest
11//! signature to execute.
12
13use 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/// Manifest describing a plugin tool.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PluginManifest {
24    /// Unique plugin identifier.
25    pub id: String,
26    /// Human-readable name.
27    pub name: String,
28    /// Semantic version.
29    pub version: String,
30    /// SHA-256 hash of the plugin content (source or binary).
31    pub content_hash: String,
32    /// Who signed this manifest.
33    pub signed_by: String,
34    /// Hex-encoded HMAC-SHA256 signature of `id|version|content_hash` using
35    /// the server's signing key.
36    pub signature: String,
37    /// Allowed capabilities (e.g., "fs:read", "net:connect", "exec:shell").
38    #[serde(default)]
39    pub capabilities: Vec<String>,
40    /// Maximum execution time in seconds.
41    #[serde(default = "default_timeout")]
42    pub timeout_secs: u64,
43}
44
45fn default_timeout() -> u64 {
46    30
47}
48
49/// Sandbox execution policy for a tool invocation.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SandboxPolicy {
52    /// Whether filesystem access is allowed (and to which paths).
53    pub allowed_paths: Vec<PathBuf>,
54    /// Whether network access is allowed.
55    pub allow_network: bool,
56    /// Whether shell execution is allowed.
57    pub allow_exec: bool,
58    /// Maximum execution time in seconds.
59    pub timeout_secs: u64,
60    /// Maximum memory in bytes (0 = no limit).
61    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, // 256 MB
72        }
73    }
74}
75
76/// Result of a sandboxed tool execution.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SandboxResult {
79    pub tool_id: String,
80    pub success: bool,
81    pub output: String,
82    /// SHA-256 hash of the combined output for integrity verification.
83    pub output_hash: String,
84    pub exit_code: Option<i32>,
85    pub duration_ms: u64,
86    pub sandbox_violations: Vec<String>,
87}
88
89/// The signing key used to verify plugin manifests.
90#[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    /// Load from `CODETETHER_PLUGIN_SIGNING_KEY` or generate a random one.
107    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    /// Load the process-wide signing key exactly once.
129    pub fn shared() -> Self {
130        GLOBAL_SIGNING_KEY.get_or_init(Self::from_env).clone()
131    }
132
133    /// Create with an explicit key (for tests).
134    #[cfg(test)]
135    pub fn with_key(key: Vec<u8>) -> Self {
136        Self { key: Arc::new(key) }
137    }
138
139    /// Sign a manifest payload: `id|version|content_hash`.
140    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    /// Verify a manifest signature.
152    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
158/// Compute SHA-256 hash of file contents.
159pub 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
167/// Compute SHA-256 hash of byte content.
168pub fn hash_bytes(data: &[u8]) -> String {
169    let mut hasher = Sha256::new();
170    hasher.update(data);
171    hex::encode(hasher.finalize())
172}
173
174/// Plugin registry — tracks registered and verified plugins.
175#[derive(Debug)]
176pub struct PluginRegistry {
177    signing_key: SigningKey,
178    /// Verified plugins: id -> manifest.
179    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    /// Register a plugin after verifying its signature.
195    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        // Verify content hash matches manifest
205        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    /// Check if a plugin is registered and verified.
226    pub async fn is_verified(&self, plugin_id: &str) -> bool {
227        self.plugins.read().await.contains_key(plugin_id)
228    }
229
230    /// Get a plugin manifest.
231    pub async fn get(&self, plugin_id: &str) -> Option<PluginManifest> {
232        self.plugins.read().await.get(plugin_id).cloned()
233    }
234
235    /// List all registered plugins.
236    pub async fn list(&self) -> Vec<PluginManifest> {
237        self.plugins.read().await.values().cloned().collect()
238    }
239
240    /// Get a reference to the signing key (for creating manifests).
241    pub fn signing_key(&self) -> &SigningKey {
242        &self.signing_key
243    }
244
245    /// Verify a plugin's content hash against a file on disk.
246    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
256/// Execute a tool in a sandboxed subprocess.
257pub 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    // Build restricted environment — strip everything except essentials.
270    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        // On Linux we can use unshare to disable networking, but as a
283        // baseline we set a marker environment variable that cooperative
284        // tools can honour and we log the restriction.
285        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
366/// Constant-time byte comparison.
367fn 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(), // tampered version
412            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}