Skip to main content

lit/commands/
sandbox.rs

1use crate::errors::LitError;
2use crate::response::SandboxResponse;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use walkdir::WalkDir;
8
9/// Metadata file placed inside every sandbox root.
10const SANDBOX_META: &str = ".sandbox.toml";
11
12/// SECURITY: Validate sandbox name to prevent path traversal.
13/// Only allows alphanumeric characters, hyphens, underscores, and dots.
14/// Rejects empty names, names starting with a dot, and any path separators.
15fn validate_sandbox_name(name: &str) -> Result<(), LitError> {
16    if name.is_empty() || name.len() > 128 {
17        return Err(LitError::general(
18            "sandbox name must be 1-128 characters".to_string(),
19        ));
20    }
21    if name.starts_with('.') {
22        return Err(LitError::general(
23            "sandbox name must not start with '.'".to_string(),
24        ));
25    }
26    if !name
27        .chars()
28        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
29    {
30        return Err(LitError::general(
31            "sandbox name contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, .)"
32                .to_string(),
33        ));
34    }
35    Ok(())
36}
37
38/// Default sandbox base directory under the repo's `.lit/` folder.
39fn sandbox_base(repo_root: &Path) -> PathBuf {
40    repo_root.join(".lit").join("sandboxes")
41}
42
43/// Resolve the sandbox root for a given name.
44fn sandbox_dir(repo_root: &Path, name: &str) -> PathBuf {
45    sandbox_base(repo_root).join(name)
46}
47
48// ── public entry points ────────────────────────────────────────────
49
50/// Create a new sandbox from the current repo working tree.
51pub fn execute_init(name: Option<String>) -> Result<SandboxResponse, LitError> {
52    let repo_root = crate::core::find_repo_root()?;
53
54    // Use CWD as the source tree when it is inside repo_root.
55    // This avoids copying the entire home directory when ~/.lit exists.
56    let cwd =
57        std::env::current_dir().map_err(|e| LitError::io(format!("cannot determine cwd: {e}")))?;
58    let source = if cwd.starts_with(&repo_root) && cwd != repo_root {
59        cwd.clone()
60    } else {
61        repo_root.clone()
62    };
63
64    let name =
65        name.unwrap_or_else(|| format!("sandbox-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")));
66    validate_sandbox_name(&name)?;
67
68    let sb_dir = sandbox_dir(&repo_root, &name);
69    if sb_dir.exists() {
70        return Err(LitError::general(format!(
71            "sandbox '{}' already exists at {}",
72            name,
73            sb_dir.display()
74        )));
75    }
76    fs::create_dir_all(&sb_dir)
77        .map_err(|e| LitError::io(format!("failed to create sandbox dir: {e}")))?;
78
79    // Copy the source tree (skip .lit/ and sandbox dir itself)
80    copy_tree(&source, &sb_dir, &repo_root)?;
81
82    // Write sandbox metadata
83    let meta = format!(
84        "# Lit sandbox metadata\ncreated = \"{}\"\nsource = \"{}\"\nname = \"{}\"\n",
85        chrono::Utc::now().to_rfc3339(),
86        source.display(),
87        name,
88    );
89    fs::write(sb_dir.join(SANDBOX_META), &meta)
90        .map_err(|e| LitError::io(format!("failed to write sandbox metadata: {e}")))?;
91
92    Ok(SandboxResponse {
93        action: "init".into(),
94        name: name.clone(),
95        path: sb_dir.display().to_string(),
96        message: format!("sandbox '{}' created", name),
97        output: None,
98        exit_code: None,
99    })
100}
101
102/// Run a command inside an existing sandbox with restricted environment.
103pub fn execute_run(name: String, cmd: Vec<String>) -> Result<SandboxResponse, LitError> {
104    let repo_root = crate::core::find_repo_root()?;
105    validate_sandbox_name(&name)?;
106    let sb_dir = sandbox_dir(&repo_root, &name);
107
108    if !sb_dir.join(SANDBOX_META).exists() {
109        return Err(LitError::general(format!(
110            "sandbox '{}' not found (expected at {})",
111            name,
112            sb_dir.display()
113        )));
114    }
115
116    if cmd.is_empty() {
117        return Err(LitError::general(String::from(
118            "no command specified - use: lit sandbox run <name> -- <command> [args...]",
119        )));
120    }
121
122    let program = &cmd[0];
123    let args = &cmd[1..];
124
125    // Build a minimal, sandboxed environment.
126    let env = sandboxed_env(&sb_dir);
127
128    let result = Command::new(program)
129        .args(args)
130        .current_dir(&sb_dir)
131        .env_clear()
132        .envs(&env)
133        .output()
134        .map_err(|e| LitError::io(format!("failed to spawn command: {e}")))?;
135
136    let stdout = String::from_utf8_lossy(&result.stdout).to_string();
137    let stderr = String::from_utf8_lossy(&result.stderr).to_string();
138    let combined = if stderr.is_empty() {
139        stdout
140    } else {
141        format!("{stdout}\n{stderr}")
142    };
143
144    let code = result.status.code().unwrap_or(-1);
145
146    Ok(SandboxResponse {
147        action: "run".into(),
148        name,
149        path: sb_dir.display().to_string(),
150        message: if result.status.success() {
151            "command completed successfully".into()
152        } else {
153            format!("command exited with code {code}")
154        },
155        output: Some(combined),
156        exit_code: Some(code),
157    })
158}
159
160/// List all sandboxes in the current repo.
161pub fn execute_list() -> Result<SandboxResponse, LitError> {
162    let repo_root = crate::core::find_repo_root()?;
163    let base = sandbox_base(&repo_root);
164
165    let mut entries = Vec::new();
166    if base.exists() {
167        for entry in fs::read_dir(&base)
168            .map_err(|e| LitError::io(format!("failed to read sandbox dir: {e}")))?
169        {
170            let entry = entry.map_err(|e| LitError::io(format!("failed to read entry: {e}")))?;
171            if entry.path().join(SANDBOX_META).exists() {
172                entries.push(entry.file_name().to_string_lossy().to_string());
173            }
174        }
175    }
176    entries.sort();
177
178    let message = if entries.is_empty() {
179        "no sandboxes".into()
180    } else {
181        entries.join("\n")
182    };
183
184    Ok(SandboxResponse {
185        action: "list".into(),
186        name: String::new(),
187        path: base.display().to_string(),
188        message,
189        output: None,
190        exit_code: None,
191    })
192}
193
194/// Destroy a sandbox.
195pub fn execute_destroy(name: String) -> Result<SandboxResponse, LitError> {
196    let repo_root = crate::core::find_repo_root()?;
197    validate_sandbox_name(&name)?;
198    let sb_dir = sandbox_dir(&repo_root, &name);
199
200    if !sb_dir.join(SANDBOX_META).exists() {
201        return Err(LitError::general(format!(
202            "sandbox '{}' not found (expected at {})",
203            name,
204            sb_dir.display()
205        )));
206    }
207
208    fs::remove_dir_all(&sb_dir)
209        .map_err(|e| LitError::io(format!("failed to remove sandbox: {e}")))?;
210
211    Ok(SandboxResponse {
212        action: "destroy".into(),
213        name: name.clone(),
214        path: sb_dir.display().to_string(),
215        message: format!("sandbox '{}' destroyed", name),
216        output: None,
217        exit_code: None,
218    })
219}
220
221// ── helpers ────────────────────────────────────────────────────────
222
223/// Copy the working tree from `src` to `dst`, skipping `.lit/` and hidden VCS dirs.
224fn copy_tree(src: &Path, dst: &Path, repo_root: &Path) -> Result<(), LitError> {
225    let skip_dirs: std::collections::HashSet<&str> =
226        [".lit", ".git", ".hg", "node_modules", "target"]
227            .iter()
228            .copied()
229            .collect();
230
231    for entry in WalkDir::new(src).into_iter().filter_entry(|e| {
232        let name = e.file_name().to_string_lossy();
233        // skip sandbox base itself
234        if e.path() == sandbox_base(repo_root) {
235            return false;
236        }
237        // SECURITY: skip symlinks to prevent sandbox escape via symlink traversal
238        if e.path_is_symlink() {
239            return false;
240        }
241        if e.file_type().is_dir() && skip_dirs.contains(name.as_ref()) {
242            return false;
243        }
244        true
245    }) {
246        let entry = match entry {
247            Ok(e) => e,
248            Err(_) => continue,
249        };
250
251        let rel = entry
252            .path()
253            .strip_prefix(src)
254            .map_err(|e| LitError::io(format!("path strip error: {e}")))?;
255
256        if rel.as_os_str().is_empty() {
257            continue;
258        }
259
260        let target = dst.join(rel);
261
262        if entry.file_type().is_dir() {
263            fs::create_dir_all(&target)
264                .map_err(|e| LitError::io(format!("mkdir {}: {e}", target.display())))?;
265        } else if entry.file_type().is_file() {
266            if let Some(parent) = target.parent() {
267                fs::create_dir_all(parent)
268                    .map_err(|e| LitError::io(format!("mkdir {}: {e}", parent.display())))?;
269            }
270            fs::copy(entry.path(), &target).map_err(|e| {
271                LitError::io(format!(
272                    "copy {} -> {}: {e}",
273                    entry.path().display(),
274                    target.display()
275                ))
276            })?;
277        }
278    }
279    Ok(())
280}
281
282/// Build a minimal environment map for the sandboxed process.
283///
284/// Only essential system paths and the sandbox HOME are exposed.
285/// Secrets, cloud tokens, user shell config, etc. are stripped.
286fn sandboxed_env(sandbox_root: &Path) -> HashMap<String, String> {
287    let mut env = HashMap::new();
288
289    // Minimal PATH — only system directories
290    #[cfg(windows)]
291    {
292        let sys_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
293        env.insert("PATH".into(), format!(r"{sys_root}\System32;{sys_root}"));
294        env.insert("SystemRoot".into(), sys_root.clone());
295        env.insert("SYSTEMDRIVE".into(), "C:".into());
296        env.insert("COMSPEC".into(), format!(r"{sys_root}\System32\cmd.exe"));
297    }
298    #[cfg(not(windows))]
299    {
300        env.insert("PATH".into(), "/usr/bin:/bin".into());
301    }
302
303    // Set HOME / USERPROFILE to sandbox root so dotfiles can't leak
304    let sb = sandbox_root.display().to_string();
305    env.insert("HOME".into(), sb.clone());
306    #[cfg(windows)]
307    env.insert("USERPROFILE".into(), sb.clone());
308
309    // Prevent Git/credential helpers from reaching real config
310    env.insert("GIT_CONFIG_NOSYSTEM".into(), "1".into());
311    env.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
312
313    // Lit-specific: output JSON, disable network
314    env.insert("LIT_OUTPUT".into(), "json".into());
315    env.insert("LIT_AIRGAPPED".into(), "1".into());
316
317    // Timezone (informational)
318    if let Ok(tz) = std::env::var("TZ") {
319        env.insert("TZ".into(), tz);
320    }
321
322    // TEMP dirs inside sandbox
323    let tmp = sandbox_root.join("tmp");
324    let _ = fs::create_dir_all(&tmp);
325    let tmp_str = tmp.display().to_string();
326    env.insert("TMPDIR".into(), tmp_str.clone());
327    env.insert("TEMP".into(), tmp_str.clone());
328    env.insert("TMP".into(), tmp_str);
329
330    env
331}