Skip to main content

vbash/
lib.rs

1//! A virtual bash environment for AI agents.
2//!
3//! Runs bash scripts in-process with a virtual filesystem. Includes
4//! `sed`, `awk`, `jq`, and most common Unix commands.
5//!
6//! ```rust,no_run
7//! use vbash::Shell;
8//!
9//! let mut shell = Shell::builder()
10//!     .file("/data/names.txt", "alice\nbob\ncharlie")
11//!     .build();
12//!
13//! let result = shell.exec("cat /data/names.txt | sort | head -n 2").unwrap();
14//! assert_eq!(result.stdout, "alice\nbob\n");
15//! assert_eq!(result.exit_code, 0);
16//! ```
17
18#![forbid(unsafe_code)]
19
20pub(crate) mod ast;
21pub(crate) mod commands;
22pub(crate) mod error;
23pub(crate) mod fs;
24pub(crate) mod interpreter;
25pub(crate) mod lexer;
26pub(crate) mod parser;
27
28use std::collections::HashMap;
29use std::sync::Arc;
30use std::sync::atomic::AtomicBool;
31
32pub use error::{Error, ExecError, FsError, LimitKind, ParseError};
33pub use commands::{CommandFn, CommandContext};
34pub use fs::VirtualFs;
35pub use fs::memory::InMemoryFs;
36pub use fs::mountable::MountableFs;
37pub use fs::overlay::OverlayFs;
38pub use fs::readwrite::ReadWriteFs;
39
40/// Result of executing a bash command string.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExecResult {
43    pub stdout: String,
44    pub stderr: String,
45    pub exit_code: i32,
46    pub env: HashMap<String, String>,
47}
48
49/// Options for a single `exec` call.
50#[derive(Default)]
51pub struct ExecOptions<'a> {
52    /// Standard input provided to the command.
53    pub stdin: Option<&'a str>,
54    /// Override environment variables for this call only.
55    pub env: Option<&'a HashMap<String, String>>,
56    /// Override working directory for this call only.
57    pub cwd: Option<&'a str>,
58    /// Cancellation flag. When set to `true`, execution will stop at the next command boundary.
59    pub cancel: Option<Arc<AtomicBool>>,
60}
61
62impl std::fmt::Debug for ExecOptions<'_> {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("ExecOptions")
65            .field("stdin", &self.stdin)
66            .field("env", &self.env)
67            .field("cwd", &self.cwd)
68            .field("cancel", &self.cancel.as_ref().map(|c| c.load(std::sync::atomic::Ordering::Relaxed)))
69            .finish()
70    }
71}
72
73/// Network access policy for the `curl` command (requires `network` feature).
74///
75/// Controls which URLs the sandboxed shell is allowed to contact.
76/// If no policy is set, all network requests are blocked (secure by default).
77#[cfg(feature = "network")]
78#[derive(Debug, Clone)]
79pub struct NetworkPolicy {
80    /// If non-empty, only URLs starting with one of these prefixes are allowed.
81    /// Uses segment-aware matching to prevent path traversal attacks.
82    pub allowed_url_prefixes: Vec<String>,
83    /// Block requests to private/loopback IP addresses and `localhost`.
84    /// Defaults to `true`.
85    pub block_private_ips: bool,
86    /// HTTP methods that are allowed. Requests with other methods are rejected.
87    pub allowed_methods: Vec<String>,
88    /// Maximum response body size in bytes. Responses exceeding this are aborted.
89    pub max_response_size: usize,
90    /// Maximum number of HTTP redirects to follow.
91    pub max_redirects: u32,
92}
93
94#[cfg(feature = "network")]
95impl Default for NetworkPolicy {
96    fn default() -> Self {
97        Self {
98            allowed_url_prefixes: Vec::new(),
99            block_private_ips: true,
100            allowed_methods: vec![
101                "GET".into(),
102                "HEAD".into(),
103                "POST".into(),
104                "PUT".into(),
105                "DELETE".into(),
106                "PATCH".into(),
107            ],
108            max_response_size: 10 * 1024 * 1024, // 10MB
109            max_redirects: 20,
110        }
111    }
112}
113
114/// Configurable execution limits to prevent runaway scripts.
115#[derive(Debug, Clone)]
116pub struct ExecutionLimits {
117    pub max_call_depth: u32,
118    pub max_command_count: u32,
119    pub max_loop_iterations: u32,
120    pub max_output_size: usize,
121    pub max_substitution_depth: u32,
122    pub max_brace_expansion: u32,
123    pub max_glob_operations: u32,
124    pub max_string_length: usize,
125    pub max_array_elements: usize,
126    pub max_source_depth: u32,
127    pub max_input_size: usize,
128}
129
130impl Default for ExecutionLimits {
131    fn default() -> Self {
132        Self {
133            max_call_depth: 100,
134            max_command_count: 10_000,
135            max_loop_iterations: 10_000,
136            max_output_size: 10 * 1024 * 1024,
137            max_substitution_depth: 50,
138            max_brace_expansion: 10_000,
139            max_glob_operations: 100_000,
140            max_string_length: 10 * 1024 * 1024,
141            max_array_elements: 100_000,
142            max_source_depth: 100,
143            max_input_size: 1_000_000,
144        }
145    }
146}
147
148#[derive(Debug, Clone)]
149pub struct SessionLimits {
150    pub max_total_commands: u64,
151    pub max_exec_calls: u64,
152}
153
154impl Default for SessionLimits {
155    fn default() -> Self {
156        Self {
157            max_total_commands: 100_000,
158            max_exec_calls: 1_000,
159        }
160    }
161}
162
163/// A virtual Bash environment.
164///
165/// Create one with [`Shell::new`] for defaults or [`Shell::builder`] for
166/// full control. The filesystem persists across [`exec`](Shell::exec) calls;
167/// shell state (variables, functions, cwd) is isolated per call.
168pub struct Shell {
169    fs: Box<dyn VirtualFs>,
170    default_env: HashMap<String, String>,
171    cwd: String,
172    limits: ExecutionLimits,
173    registry: commands::CommandRegistry,
174    #[cfg(feature = "network")]
175    network_policy: Option<NetworkPolicy>,
176    session_limits: Option<SessionLimits>,
177    session_command_count: u64,
178    session_exec_count: u64,
179}
180
181impl Shell {
182    /// Create an instance with default settings and an empty in-memory filesystem.
183    pub fn new() -> Self {
184        Builder::new().build()
185    }
186
187    /// Start building a configured instance.
188    pub fn builder() -> Builder {
189        Builder::new()
190    }
191
192    /// Execute a bash command string.
193    ///
194    /// Shell state (variables, functions, working directory) is isolated
195    /// per call - each invocation starts fresh. The filesystem is shared
196    /// and persists across calls.
197    ///
198    /// # Errors
199    ///
200    /// Returns `Error::Parse` for syntax errors, `Error::LimitExceeded`
201    /// if an execution limit is hit. Note that a non-zero exit code is **not**
202    /// an error - it's reported in [`ExecResult::exit_code`].
203    pub fn exec(&mut self, command: &str) -> Result<ExecResult, Error> {
204        self.check_session_limits()?;
205        if command.len() > self.limits.max_input_size {
206            return Err(Error::LimitExceeded(crate::error::LimitKind::InputSize));
207        }
208        let script = parser::parse(command)?;
209        let (result, cmd_count) = interpreter::execute(
210            &script,
211            &*self.fs,
212            &self.default_env,
213            &self.cwd,
214            &self.limits,
215            &self.registry,
216            "",
217            None,
218            #[cfg(feature = "network")]
219            self.network_policy.as_ref(),
220        );
221        self.update_session_counters(cmd_count);
222        result
223    }
224
225    /// Execute with custom options (stdin, env overrides, cwd override).
226    #[allow(clippy::needless_pass_by_value)]
227    pub fn exec_with(&mut self, command: &str, options: ExecOptions<'_>) -> Result<ExecResult, Error> {
228        self.check_session_limits()?;
229        if command.len() > self.limits.max_input_size {
230            return Err(Error::LimitExceeded(crate::error::LimitKind::InputSize));
231        }
232        let script = parser::parse(command)?;
233        let env = match options.env {
234            Some(override_env) => {
235                let mut e = self.default_env.clone();
236                e.extend(override_env.iter().map(|(k, v)| (k.clone(), v.clone())));
237                e
238            }
239            None => self.default_env.clone(),
240        };
241        let cwd = options.cwd.unwrap_or(&self.cwd);
242        let stdin = options.stdin.unwrap_or("");
243        let (result, cmd_count) = interpreter::execute(
244            &script,
245            &*self.fs,
246            &env,
247            cwd,
248            &self.limits,
249            &self.registry,
250            stdin,
251            options.cancel,
252            #[cfg(feature = "network")]
253            self.network_policy.as_ref(),
254        );
255        self.update_session_counters(cmd_count);
256        result
257    }
258
259    /// Register a custom command after construction.
260    pub fn register_command(&mut self, name: impl Into<String>, func: CommandFn) {
261        self.registry.register(name.into(), func);
262    }
263
264    /// Execute a command with a timeout. Spawns a timer thread that cancels
265    /// execution after the given duration.
266    pub fn exec_with_timeout(&mut self, command: &str, timeout: std::time::Duration) -> Result<ExecResult, Error> {
267        let cancel = Arc::new(AtomicBool::new(false));
268        let cancel_clone = cancel.clone();
269        std::thread::spawn(move || {
270            std::thread::sleep(timeout);
271            cancel_clone.store(true, std::sync::atomic::Ordering::Relaxed);
272        });
273        self.exec_with(command, ExecOptions {
274            cancel: Some(cancel),
275            ..Default::default()
276        })
277    }
278
279    /// Direct access to the virtual filesystem.
280    pub fn fs(&self) -> &dyn VirtualFs {
281        &*self.fs
282    }
283
284    /// Read a file as a UTF-8 string.
285    pub fn read_file(&self, path: &str) -> Result<String, Error> {
286        self.fs.read_file_string(path).map_err(Error::Fs)
287    }
288
289    /// Write a UTF-8 string to a file (creates parent directories).
290    pub fn write_file(&self, path: &str, content: &str) -> Result<(), Error> {
291        self.fs.write_file(path, content.as_bytes()).map_err(Error::Fs)
292    }
293
294    /// Current working directory.
295    pub fn cwd(&self) -> &str {
296        &self.cwd
297    }
298
299    /// Current default environment variables.
300    pub fn env(&self) -> &HashMap<String, String> {
301        &self.default_env
302    }
303
304    fn check_session_limits(&self) -> Result<(), Error> {
305        if let Some(ref sl) = self.session_limits {
306            if self.session_exec_count >= sl.max_exec_calls {
307                return Err(Error::LimitExceeded(crate::error::LimitKind::SessionExecCalls));
308            }
309            if self.session_command_count >= sl.max_total_commands {
310                return Err(Error::LimitExceeded(crate::error::LimitKind::SessionCommands));
311            }
312        }
313        Ok(())
314    }
315
316    fn update_session_counters(&mut self, commands_run: u32) {
317        self.session_exec_count += 1;
318        self.session_command_count += u64::from(commands_run);
319    }
320}
321
322impl Default for Shell {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl std::fmt::Debug for Shell {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        f.debug_struct("Shell")
331            .field("cwd", &self.cwd)
332            .field("env_count", &self.default_env.len())
333            .finish_non_exhaustive()
334    }
335}
336
337/// Builder for configuring an [`Shell`] instance.
338pub struct Builder {
339    fs: Option<Box<dyn VirtualFs>>,
340    env: HashMap<String, String>,
341    files: Vec<(String, String)>,
342    cwd: String,
343    limits: ExecutionLimits,
344    custom_commands: Vec<(String, CommandFn)>,
345    #[cfg(feature = "network")]
346    network_policy: Option<NetworkPolicy>,
347    session_limits: Option<SessionLimits>,
348}
349
350impl Builder {
351    /// Create a new builder with default settings.
352    pub fn new() -> Self {
353        Self {
354            fs: None,
355            env: HashMap::new(),
356            files: Vec::new(),
357            cwd: String::from("/home/user"),
358            limits: ExecutionLimits::default(),
359            custom_commands: Vec::new(),
360            #[cfg(feature = "network")]
361            network_policy: None,
362            session_limits: None,
363        }
364    }
365
366    /// Use a custom filesystem implementation.
367    #[must_use]
368    pub fn fs(mut self, fs: impl VirtualFs + 'static) -> Self {
369        self.fs = Some(Box::new(fs));
370        self
371    }
372
373    /// Set an environment variable.
374    #[must_use]
375    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
376        self.env.insert(key.into(), value.into());
377        self
378    }
379
380    /// Set multiple environment variables.
381    #[must_use]
382    pub fn envs(mut self, vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
383        for (k, v) in vars {
384            self.env.insert(k.into(), v.into());
385        }
386        self
387    }
388
389    /// Set the initial working directory.
390    #[must_use]
391    pub fn cwd(mut self, dir: impl Into<String>) -> Self {
392        self.cwd = dir.into();
393        self
394    }
395
396    /// Pre-populate a file in the virtual filesystem.
397    #[must_use]
398    pub fn file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
399        self.files.push((path.into(), content.into()));
400        self
401    }
402
403    /// Set execution limits.
404    #[must_use]
405    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
406        self.limits = limits;
407        self
408    }
409
410    #[must_use]
411    pub fn session_limits(mut self, limits: SessionLimits) -> Self {
412        self.session_limits = Some(limits);
413        self
414    }
415
416    /// Register a custom command that will be available in the shell.
417    #[must_use]
418    pub fn command(mut self, name: impl Into<String>, func: CommandFn) -> Self {
419        self.custom_commands.push((name.into(), func));
420        self
421    }
422
423    /// Set a network policy for the `curl` command.
424    ///
425    /// When the `network` feature is enabled, all network requests are blocked
426    /// by default unless a policy is explicitly configured via this method.
427    #[cfg(feature = "network")]
428    #[must_use]
429    pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {
430        self.network_policy = Some(policy);
431        self
432    }
433
434    /// Build the configured [`Shell`] instance.
435    pub fn build(self) -> Shell {
436        let fs: Box<dyn VirtualFs> = match self.fs {
437            Some(fs) => fs,
438            None => Box::new(InMemoryFs::new()),
439        };
440
441        let _ = fs.mkdir("/bin", true);
442        let _ = fs.mkdir("/usr/bin", true);
443        let _ = fs.mkdir("/tmp", true);
444        let _ = fs.mkdir("/dev", true);
445        let _ = fs.mkdir("/home/user", true);
446        let _ = fs.mkdir("/proc", true);
447        let _ = fs.mkdir("/proc/self", true);
448        let _ = fs.write_file("/dev/null", b"");
449        let _ = fs.write_file("/proc/version", b"Linux vbash 5.15.0 x86_64\n");
450        let _ = fs.write_file("/proc/self/exe", b"/bin/bash\n");
451
452        for (path, content) in &self.files {
453            if let Some(parent_end) = path.rfind('/') {
454                if parent_end > 0 {
455                    let _ = fs.mkdir(&path[..parent_end], true);
456                }
457            }
458            let _ = fs.write_file(path, content.as_bytes());
459        }
460
461        let mut env = self.env;
462        env.entry("HOME".to_string())
463            .or_insert_with(|| "/home/user".to_string());
464        env.entry("PATH".to_string())
465            .or_insert_with(|| "/usr/bin:/bin".to_string());
466        env.entry("PWD".to_string())
467            .or_insert_with(|| self.cwd.clone());
468        env.entry("USER".to_string())
469            .or_insert_with(|| "user".to_string());
470        env.entry("HOSTNAME".to_string())
471            .or_insert_with(|| "vbash".to_string());
472        env.entry("SHELL".to_string())
473            .or_insert_with(|| "/bin/bash".to_string());
474        env.entry("TERM".to_string())
475            .or_insert_with(|| "xterm-256color".to_string());
476        env.entry("IFS".to_string())
477            .or_insert_with(|| " \t\n".to_string());
478        env.entry("OLDPWD".to_string())
479            .or_insert_with(|| self.cwd.clone());
480        env.entry("OSTYPE".to_string())
481            .or_insert_with(|| "linux-gnu".to_string());
482        env.entry("MACHTYPE".to_string())
483            .or_insert_with(|| "x86_64-pc-linux-gnu".to_string());
484        env.entry("HOSTTYPE".to_string())
485            .or_insert_with(|| "x86_64".to_string());
486        env.entry("BASH_VERSION".to_string())
487            .or_insert_with(|| "5.2.0-vbash".to_string());
488
489        let mut registry = commands::CommandRegistry::new();
490        for (name, func) in self.custom_commands {
491            registry.register(name, func);
492        }
493
494        Shell {
495            fs,
496            default_env: env,
497            cwd: self.cwd,
498            limits: self.limits,
499            registry,
500            #[cfg(feature = "network")]
501            network_policy: self.network_policy,
502            session_limits: self.session_limits,
503            session_command_count: 0,
504            session_exec_count: 0,
505        }
506    }
507}
508
509impl Default for Builder {
510    fn default() -> Self {
511        Self::new()
512    }
513}