Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ bash-*.tar

.grepai/
docs/plans
.claude
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ power - call Elixir functions directly from Bash pipelines.
| **Full I/O support** | Redirections, pipes, heredocs, process substitution |
| **Job control** | Background jobs, fg/bg switching, signal handling |
| **Streaming output** | Process stdout/stderr incrementally with configurable sinks |
| **Sandboxing** | Pluggable virtual filesystem and restricted mode for isolated execution |

## Usage

Expand Down Expand Up @@ -217,6 +218,26 @@ Bash.stdout(result)
#=> "HELLO\n"
```

### Sandboxing

Run scripts against a virtual filesystem to isolate them from the host OS:

```elixir
# Provide a custom filesystem adapter
{:ok, session} = Bash.Session.new(filesystem: {MyVFS, vfs_config})

# Builtins (echo, test, cd, etc.) operate on the VFS.
# External commands (ls, cat, grep, etc.) are automatically blocked —
# restricted mode is enforced whenever a non-LocalDisk filesystem is used.
{:ok, result, _} = Bash.run("test -f /data/input.csv && echo found", session)
```

To use restricted mode with the real filesystem (block external commands without a VFS):

```elixir
{:ok, session} = Bash.Session.new(options: %{restricted: true})
```

## Supported Features

### Control Flow
Expand Down
56 changes: 56 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The shell is built around GenServer-based session management with a multi-layer
- **Coproc** - GenServer managing coprocess I/O in external or internal mode
- **ProcessSubst** - GenServer managing process substitution FIFOs
- **Sink** - Pluggable output destinations
- **ExternalProcess** - Centralized OS process gateway (restricted mode enforcement)
- **Filesystem** - Pluggable filesystem behaviour (sandboxed I/O)

## Supervision Tree

Expand Down Expand Up @@ -87,12 +89,15 @@ stateDiagram-v2
| `special_vars` | Map | `$?`, `$!`, `$$`, `$0`, `$_` |
| `positional_params` | list | Function argument stack |
| `file_descriptors` | Map | FD number to pid or `{:coproc, pid, :read \| :write}` |
| `options` | Map | Shell options including `restricted`, `hashall`, `braceexpand` |
| `filesystem` | `{module, config}` | Pluggable filesystem adapter (default: `{LocalDisk, nil}`) |

**Internal fields (opaque):**
- `job_supervisor`, `output_collector` - Process pids
- `stdout_sink`, `stderr_sink` - Sink functions
- `executions`, `current`, `is_pipeline_tail` - Execution tracking
- `file_descriptors` - Routes FD reads/writes to coproc GenServers or StringIO devices
- `filesystem` - Dispatches all file I/O through the pluggable adapter

### OutputCollector GenServer

Expand Down Expand Up @@ -466,6 +471,57 @@ Background job state (output flows to OutputCollector via sinks):
}
```

## Sandboxing

### ExternalProcess

Centralized gateway wrapping all user-facing OS process spawning (`ExCmd.Process`, `ExCmd.stream`, `System.cmd`). When restricted mode is active, all calls return `{:error, :restricted}`.

```mermaid
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]
```

Internal plumbing (signal delivery via `kill`, hostname/uname lookup, `mkfifo`) bypasses this gateway.

Enabled via `Bash.Session.new(restricted: true)` or automatically when a non-LocalDisk filesystem is provided (see below). The flag is immutable — `set` cannot toggle it, and `shopt restricted_shell` reflects actual state read-only.

### Filesystem

Behaviour and dispatcher for pluggable filesystem implementations. 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.

```mermaid
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]
```

The default adapter `Bash.Filesystem.LocalDisk` delegates to Elixir's `File` and Erlang's `:file` modules. Custom adapters implement the `Bash.Filesystem` behaviour (16 callbacks).

Enabled via `Bash.Session.new(filesystem: {MyVFS, config})`. Inherits to child sessions.

### Auto-enforcement

When a non-LocalDisk filesystem is provided, `Session.init/1` automatically enables restricted mode. This prevents a split-brain state where builtins operate on the virtual filesystem while external commands (`ls`, `cat`, etc.) bypass it and hit the real OS. With auto-enforcement, there is no way to create a session that uses a VFS for builtins but allows unrestricted external process execution.

`Bash.Filesystem.local_disk?/1` is the predicate used to decide whether enforcement applies.

## Design Patterns

### Async Output Collection
Expand Down
44 changes: 33 additions & 11 deletions lib/bash/ast/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ defmodule Bash.AST.Command do

@doc false
def process_input_redirects(redirects, _session_state, default_stdin)
when redirects in [nil, []], do: default_stdin
when redirects in [nil, []],
do: default_stdin

def process_input_redirects(redirects, session_state, default_stdin) do
redirects
Expand Down Expand Up @@ -537,17 +538,17 @@ defmodule Bash.AST.Command do
append = dir == :append

# Check noclobber (set -C): cannot overwrite existing file with >
if noclobber && !append && File.exists?(file_path) do
if noclobber && !append && Bash.Filesystem.exists?(session_state.filesystem, file_path) do
error_msg = "bash: #{file_path}: cannot overwrite existing file\n"
{:error, error_msg, state}
else
create_file_sink_for_redirect(file_path, append, fd, state)
create_file_sink_for_redirect(file_path, append, fd, state, session_state)
end
end

defp create_file_sink_for_redirect(file_path, append, fd, state) do
defp create_file_sink_for_redirect(file_path, append, fd, state, session_state) do
# Ensure parent directory exists
file_path |> Path.dirname() |> File.mkdir_p()
file_path |> Path.dirname() |> then(&Bash.Filesystem.mkdir_p(session_state.filesystem, &1))

# Set stream_type based on which fd this sink is for
# This matters when FD duplication re-tags chunks (e.g., 1>&2 sends :stderr to this sink)
Expand All @@ -558,7 +559,12 @@ defmodule Bash.AST.Command do
_ -> :stdout
end

{sink, close_fn} = Sink.file(file_path, append: append, stream_type: stream_type)
{sink, close_fn} =
Sink.file(file_path,
append: append,
stream_type: stream_type,
filesystem: session_state.filesystem
)

new_state =
case fd do
Expand Down Expand Up @@ -672,7 +678,7 @@ defmodule Bash.AST.Command do
) do
file_word
|> resolve_redirect_path(session_state)
|> File.read()
|> then(&Bash.Filesystem.read(session_state.filesystem, &1))
|> case do
{:ok, content} -> content
{:error, _} -> default_stdin
Expand Down Expand Up @@ -881,7 +887,14 @@ defmodule Bash.AST.Command do
# This ensures external command output is interleaved correctly with builtin output
combined_sink = build_combined_sink(session_state)

base_opts = [cd: session_state.working_dir, env: env, stdin: stdin, timeout: 5000]
base_opts = [
cd: session_state.working_dir,
env: env,
stdin: stdin,
timeout: 5000,
restricted: Bash.ExternalProcess.restricted?(session_state)
]

opts = if combined_sink, do: [{:sink, combined_sink} | base_opts], else: base_opts

# Resolve command through hash table or PATH
Expand Down Expand Up @@ -926,7 +939,8 @@ defmodule Bash.AST.Command do
case Map.get(hash_table, command_name) do
{hit_count, cached_path} ->
# Found in hash table - verify path still exists
if File.exists?(cached_path) and not File.dir?(cached_path) do
if Bash.Filesystem.exists?(session_state.filesystem, cached_path) and
not Bash.Filesystem.dir?(session_state.filesystem, cached_path) do
# Path valid - increment hit count and use cached path
{cached_path, %{hash_updates: %{command_name => {hit_count + 1, cached_path}}}}
else
Expand Down Expand Up @@ -979,7 +993,8 @@ defmodule Bash.AST.Command do
Enum.find_value(path_dirs, fn dir ->
full_path = Path.join(dir, command_name)

if File.exists?(full_path) and not File.dir?(full_path) do
if Bash.Filesystem.exists?(session_state.filesystem, full_path) and
not Bash.Filesystem.dir?(session_state.filesystem, full_path) do
full_path
end
end)
Expand Down Expand Up @@ -1018,7 +1033,14 @@ defmodule Bash.AST.Command do
# Create a combined sink that routes stdout/stderr to the session's sinks
combined_sink = build_combined_sink(session_state)

base_opts = [cd: session_state.working_dir, env: env, stdin: stdin, timeout: 5000]
base_opts = [
cd: session_state.working_dir,
env: env,
stdin: stdin,
timeout: 5000,
restricted: Bash.ExternalProcess.restricted?(session_state)
]

opts = if combined_sink, do: [{:sink, combined_sink} | base_opts], else: base_opts

CommandPort.execute("bash", ["-c", full_cmd], opts)
Expand Down
17 changes: 13 additions & 4 deletions lib/bash/ast/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@ defmodule Bash.AST.Helpers do
{Path.join(session_state.working_dir, pattern), false, has_dot}
end

case Path.wildcard(glob_path, match_dot: false) do
case Bash.Filesystem.wildcard(session_state.filesystem, glob_path, match_dot: false) do
[] ->
pattern

Expand Down Expand Up @@ -1027,7 +1027,7 @@ defmodule Bash.AST.Helpers do
listing_dir =
if is_absolute, do: dir, else: Path.join(session_state.working_dir, dir || "")

case File.ls(listing_dir) do
case Bash.Filesystem.ls(session_state.filesystem, listing_dir) do
{:ok, entries} ->
matches =
entries
Expand Down Expand Up @@ -1077,7 +1077,7 @@ defmodule Bash.AST.Helpers do
Path.join(session_state.working_dir, dir)
end

if File.dir?(full_dir) do
if Bash.Filesystem.dir?(session_state.filesystem, full_dir) do
{is_absolute, has_dot_prefix, dir, file}
else
# Directory part contains glob chars, fall back to standard
Expand Down Expand Up @@ -1379,9 +1379,18 @@ defmodule Bash.AST.Helpers do
_result = Executor.execute(ast, subst_session, nil)

# Extract stdout from the temporary collector
{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

# Convert iodata to string and trim trailing newline (bash behavior)
IO.iodata_to_binary(stdout_iodata)
|> String.trim_trailing("\n")
Expand Down
49 changes: 43 additions & 6 deletions lib/bash/ast/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule Bash.AST.Pipeline do
alias Bash.AST.Helpers
alias Bash.Builtin
alias Bash.Executor
alias Bash.ExternalProcess
alias Bash.OutputCollector
alias Bash.Sink
alias Bash.Variable
Expand Down Expand Up @@ -291,9 +292,18 @@ defmodule Bash.AST.Pipeline do
end

# Extract output from the temporary collector
{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

output = IO.iodata_to_binary(stdout_iodata)
{exit_code, env_updates} = result

Expand Down Expand Up @@ -488,9 +498,18 @@ defmodule Bash.AST.Pipeline do
{result.exit_code || 0, %{}}
end

{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

output = IO.iodata_to_binary(stdout_iodata)
{exit_code, env_updates} = result

Expand All @@ -514,6 +533,8 @@ defmodule Bash.AST.Pipeline do

# Check if a single command is external (not a builtin or function) and has no redirects.
# Commands with redirects need sequential execution to handle the redirect logic.
defp external_command?(_command, %{options: %{restricted: true}}), do: false

defp external_command?(%AST.Command{name: name, redirects: redirects}, session_state) do
# Commands with redirects can't use simple streaming
if redirects != [] do
Expand Down Expand Up @@ -625,13 +646,21 @@ defmodule Bash.AST.Pipeline do
# Build nested ExCmd.stream calls from innermost (first command) to outermost (last command)
defp build_stream_pipeline([cmd], stdin, session_state) do
{name, args, env} = resolve_external_command(cmd, session_state)
restricted = ExternalProcess.restricted?(session_state)

ExCmd.stream([name | args],
opts = [
input: stdin,
cd: session_state.working_dir,
env: env,
stderr: :redirect_to_stdout
)
]

case ExternalProcess.stream([name | args], opts, restricted) do
# Safety net: external_command?/2 returns false in restricted mode,
# so this branch should be unreachable. Returns empty stream as a fallback.
{:error, :restricted} -> Stream.map([], & &1)
stream -> stream
end
end

defp build_stream_pipeline([cmd | rest], stdin, session_state) do
Expand All @@ -648,13 +677,21 @@ defmodule Bash.AST.Pipeline do
end)

{name, args, env} = resolve_external_command(cmd, session_state)
restricted = ExternalProcess.restricted?(session_state)

ExCmd.stream([name | args],
opts = [
input: filtered_upstream,
cd: session_state.working_dir,
env: env,
stderr: :redirect_to_stdout
)
]

case ExternalProcess.stream([name | args], opts, restricted) do
# Safety net: external_command?/2 returns false in restricted mode,
# so this branch should be unreachable. Returns empty stream as a fallback.
{:error, :restricted} -> Stream.map([], & &1)
stream -> stream
end
end

# Resolve command name, args, and environment from AST
Expand Down
Loading