Skip to content

Implement sandboxed execution: restricted mode + pluggable virtual filesystem#4

Open
lostbean wants to merge 5 commits intotv-labs:mainfrom
lostbean:egomes/sandbox
Open

Implement sandboxed execution: restricted mode + pluggable virtual filesystem#4
lostbean wants to merge 5 commits intotv-labs:mainfrom
lostbean:egomes/sandbox

Conversation

@lostbean
Copy link
Contributor

Note: This PR depends on #3 (test fixes, already approved). The 4 test failures visible in CI are from pre-existing platform-dependent tests fixed in that PR — once #3 is merged, this branch will be green.

Motivation

When a command is not a builtin, a user-defined function, or an Interop binding, the interpreter falls through to spawning a real OS process via ExCmd. Commands like ls, cat, grep, curl, rm execute against the host with full access to the filesystem, network, and process table. There is no option to disable this fallback, and all filesystem access goes through File.* directly. This means there is no way to use the library as a sandboxed scripting environment.

Use Case

  • AI Agent Shell Environment — An LLM-driven agent needs bash scripting constructs (variables, loops, conditionals, functions, string manipulation, arrays, arithmetic) against a virtual workspace via Interop bindings. Without sandboxing, an agent could run curl, rm -rf /, or any installed binary, and builtins like test -f or source would hit the real filesystem.
  • Multi-Tenant Script Execution — A platform executing user-provided bash scripts needs to guarantee scripts cannot escape their sandbox, both in terms of process execution and filesystem access.
  • Testing and Simulation — Tests exercising bash scripting logic should not depend on or affect the host filesystem. A virtual filesystem gives deterministic, isolated execution suitable for CI.

Requirements

  • No code path may spawn an OS process in response to a user command (simple commands, absolute paths, command, exec, coproc, pipelines, command substitution, background jobs).
  • All builtins, Interop commands, and user-defined bash functions remain fully functional.
  • Blocked commands produce a stderr message mentioning "restricted" and return a non-zero exit code.
  • The restricted flag inherits to subshells and command substitutions and is immutable once set.
  • All filesystem access (builtins like test, cd, source, pwd, redirections, glob expansion) dispatches through a pluggable filesystem behaviour instead of calling File.* directly.
  • When a non-LocalDisk filesystem is provided, restricted mode is automatically enforced.
  • Default behavior is unchanged — purely additive, opt-in.

Proposed Solution

The solution has two layers:

1. Restricted Mode (Bash.ExternalProcess)

A new Bash.ExternalProcess module acts as a centralized gateway wrapping all user-facing OS process spawning (ExCmd.Process.start_link, ExCmd.stream, System.cmd). When restricted mode is active, all calls return {:error, :restricted}.

graph TD
    CP[CommandPort] --> EP[ExternalProcess]
    JP[JobProcess] --> EP
    CO[Coproc] --> EP
    PL[Pipeline] --> EP
    CM[Command builtin] --> EP
    EP -->|restricted?| ERR["{:error, :restricted}"]
    EP -->|allowed| EX[ExCmd / System.cmd]
Loading

The session carries the flag in options.restricted, defaulting to false. The set builtin filters :restricted from option changes, and shopt treats restricted_shell as read-only.

2. Pluggable Filesystem (Bash.Filesystem)

A behaviour with 16 callbacks covering all filesystem operations used by the interpreter. Stored in session state as a {module, config} tuple. All filesystem access (57+ call sites across 14 files) dispatches through Bash.Filesystem instead of calling File.* directly.

graph TD
    subgraph Callers
        T[Test builtin]
        CD[cd/pwd]
        SRC[source]
        CMD[Command/Pipeline]
        R[Redirections]
        G[Glob expansion]
    end
    Callers --> FS[Bash.Filesystem]
    FS --> LD[LocalDisk — default]
    FS --> VFS[Custom VFS adapter]
Loading

The default Bash.Filesystem.LocalDisk adapter delegates to Elixir's File and Erlang's :file modules. When a non-LocalDisk filesystem is provided, Session.init/1 automatically enables restricted mode to prevent a split-brain state where builtins use the VFS but external commands hit the real OS.

Usage

# Restricted mode only (real filesystem, no external commands)
{:ok, session} = Bash.Session.new(options: %{restricted: true})

# Full sandbox (virtual filesystem + auto-restricted)
{:ok, session} = Bash.Session.new(filesystem: {MyVFS, vfs_config})

Add Bash.ExternalProcess as a centralized gateway wrapping all user-facing
OS process spawning (ExCmd.Process.start_link, ExCmd.stream, System.cmd).
When restricted mode is active, all three return {:error, :restricted}.

Thread the restricted flag from Session options through CommandPort,
AST.Command, Pipeline, JobProcess, and the exec/command/coproc builtins.
Guard Pipeline.external_command?/2 to force sequential dispatch in
restricted mode. Protect the restricted flag from mutation in set.ex and
wire shopt restricted_shell as a read-only query reflecting actual state.

Add Bash.ExternalProcess module with defguard-based pattern matching.
Add restricted: false to session default options and all three job_opts
construction sites in session.ex. Wrap JobProcess.await_start with
try/catch for the case where the GenServer exits on restricted failure.
Use Map.get/3 for options access to handle bare state maps in tests.
Forward stderr from temp collectors in pipeline and command substitution
to parent session sinks. Add {:exec, result} handling to SessionCase.
Add a `Bash.Filesystem` behaviour with 16 callbacks (exists?, dir?,
regular?, stat, lstat, read, write, mkdir_p, rm, open, handle_write,
handle_close, ls, wildcard, read_link, read_link_all) and a dispatcher
that unwraps {module, config} tuples from session state.

`Bash.Filesystem.LocalDisk` is the default adapter, delegating to
Elixir's `File` and Erlang's `:file` modules — preserving identical
behavior for sessions created without a `filesystem:` option.

The `filesystem` field is added to session state, initialized from
opts in `init/1`, and inherited by child sessions in `new_child/2`.

All 57+ filesystem access points across 14 files are refactored to
dispatch through `Bash.Filesystem` instead of calling `File.*` or
`:file.*` directly:

- builtin/test.ex: all 17+ file test operators (-e, -f, -d, -r, -w,
  -x, -s, -L, -b, -c, -p, -S, -g, -k, -u, -N, -nt, -ot, -ef)
- builtin/cd.ex: resolve_path, search_cdpath, validate_directory,
  resolve_symlinks
- builtin/pwd.ex: resolve_path_components symlink resolution
- builtin/source.ex: file existence checks and file reading
- builtin/pushd.ex, popd.ex: validate_directory (signature changed
  to accept session_state)
- builtin/command.ex, type.ex, hash.ex: find_in_path PATH lookup
- ast/command.ex: noclobber check, mkdir_p, input redirect read,
  PATH lookup in resolve_via_hash_or_path and find_command_in_path
- ast/helpers.ex: glob expansion (wildcard, ls, dir?)
- ast/while_loop.ex: input redirect file reading
- session.ex: chdir validation, open_fd, close_fd,
  close_all_file_descriptors
- sink.ex: file/2 accepts filesystem: option for open/write/close

Test suite includes 49 new tests:
- 18 functional VFS tests with an InMemory adapter exercising test
  operators, source, redirections, globs, cd/pwd, child context
  propagation, and combined restricted+VFS mode
- 28 enforcement tests using poison-path technique (working dir at
  /nonexistent_vfs_enforcement_path/workspace) that fail if any code
  path leaks to direct File.* calls, covering all categories above
  plus noclobber, while-loop redirects, and command/type/hash lookup
- 3 existing test files updated to include filesystem key in mock
  session state maps
Add Bash.Filesystem.local_disk?/1 to distinguish LocalDisk from custom
VFS implementations. In Session.init/1, automatically set restricted: true
when a non-LocalDisk filesystem is provided, eliminating the split-brain
state where builtins see the VFS but external commands hit the real OS.

Narrow the restricted-mode PATH lookup guard in Builtin.Command to only
block find_in_path/2 for LocalDisk sessions. VFS sessions still resolve
commands via the virtual filesystem since ExternalProcess independently
blocks execution.

Add tests verifying auto-enforcement sets the restricted flag, reports
restricted_shell as on via shopt, blocks external commands, and leaves
LocalDisk sessions unrestricted by default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant