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
91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ bash = Bash(
env={...}, # Environment variables
cwd="/home/user", # Working directory
network=NetworkConfig(...), # Network configuration (for curl)
fetch=custom_fetch, # Custom async fetch function (for curl)
unescape_html=True, # Auto-fix HTML entities in LLM output (default: True)
)
```
Expand Down Expand Up @@ -271,6 +272,92 @@ bash = Bash(unescape_html=False)
- **Filesystem isolation** - Virtual filesystem keeps host system safe
- **SQLite sandboxed** - Only in-memory databases allowed

### Network Access

Network access is disabled by default. Configure it explicitly to enable `curl`:

```python
from just_bash import Bash, NetworkConfig

bash = Bash(
network=NetworkConfig(
allowed_url_prefixes=["https://api.example.com/v1/"],
)
)

result = await bash.exec("curl -s https://api.example.com/v1/status")
```

`curl` is registered only when `network` or a custom `fetch` function is provided.
Without network configuration, `curl` returns "command not found".

Allow additional HTTP methods when needed:

```python
bash = Bash(
network=NetworkConfig(
allowed_url_prefixes=["https://api.example.com/"],
allowed_methods=["GET", "HEAD", "POST"],
)
)
```

Inject trusted headers at the network boundary so secrets never enter the sandbox:

```python
from just_bash import AllowedUrl, Bash, NetworkConfig, RequestTransform

bash = Bash(
network=NetworkConfig(
allowed_url_prefixes=[
AllowedUrl(
url="https://api.example.com/",
transform=[
RequestTransform(headers={"Authorization": "Bearer secret"})
],
)
],
)
)
```

Allow all URLs and methods only when the caller already trusts the sandboxed command:

```python
bash = Bash(
network=NetworkConfig(dangerously_allow_full_internet_access=True)
)
```

Pass `fetch=custom_fetch` to provide your own async `fetch(url, options)`
implementation. The default fetch implementation uses `aiohttp` and enforces URL
prefixes, HTTP methods, redirects, timeouts, response-size limits, and optional
private-range blocking.

#### Allow-List Security

The allow-list enforces:

- **Origin matching** - URLs must match the exact scheme, host, and port
- **Path prefix** - Only paths starting with the configured prefix are allowed
- **HTTP method restrictions** - Only GET and HEAD are allowed by default
- **Redirect protection** - Redirect targets are checked before following them
- **Header transforms** - Boundary-injected headers override sandbox-supplied headers with the same name

#### Using curl

```bash
# Fetch and process data
curl -s https://api.example.com/data | grep pattern

# Download into the virtual filesystem
curl -fsSL -o response.json https://api.example.com/data

# POST JSON data
curl -X POST -H "Content-Type: application/json" \
-d '{"key":"value"}' https://api.example.com/endpoint
```

## Supported Features

### Shell Syntax
Expand Down Expand Up @@ -395,7 +482,7 @@ bash sh

## Test Results

Test suite history per commit (spec_tests excluded). Each `█` ≈ 57 tests.
Test suite history per commit (spec_tests excluded). Each `█` ≈ 58 tests.

```
Commit Date Passed Failed Skipped Graph
Expand All @@ -410,6 +497,8 @@ a7e64a4 2026-02-11 2825 0 2 ███████████
e736ca4 2026-02-17 2831 0 2 ███████████████████████████████████████████████████░
4dddca8 2026-02-18 2849 0 2 █████████████████████████████████████████████████░
7c83ff3 2026-02-18 2870 0 3 █████████████████████████████████████████████████░
bbb2f27 2026-05-19 2884 0 3 █████████████████████████████████████████████████░
ad7fcdb 2026-05-19 2903 0 3 █████████████████████████████████████████████████░
```

`█` passed · `▒` failed · `░` skipped
Expand Down
6 changes: 6 additions & 0 deletions src/just_bash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .bash import Bash
from .types import (
AllowedUrl,
ExecResult,
BashExecResult,
ExecutionLimits,
Expand All @@ -23,6 +24,8 @@
Command,
OK,
FAIL,
RequestTransform,
SecureFetch,
)
from .fs import InMemoryFs
from .parser import Parser, parse, ParseException
Expand All @@ -37,6 +40,9 @@
"BashExecResult",
"ExecutionLimits",
"NetworkConfig",
"AllowedUrl",
"RequestTransform",
"SecureFetch",
"IFileSystem",
"FsStat",
"CommandContext",
Expand Down
20 changes: 17 additions & 3 deletions src/just_bash/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
import nest_asyncio # type: ignore[import-untyped]

from .commands import create_command_registry
from .commands.registry import create_network_lazy_commands
from .fs import InMemoryFs
from .interpreter import ExitError, Interpreter, InterpreterState, ShellOptions, VariableStore
from .network import make_default_fetch
from .parser import parse, unescape_html_entities
from .parser.parser import ParseException
from .types import (
Expand All @@ -37,6 +39,7 @@
ExecutionLimits,
IFileSystem,
NetworkConfig,
SecureFetch,
)


Expand All @@ -56,6 +59,7 @@ def __init__(
env: Optional[dict[str, str]] = None,
limits: Optional[ExecutionLimits] = None,
network: Optional[NetworkConfig] = None,
fetch: Optional[SecureFetch] = None,
commands: Optional[dict[str, Command]] = None,
errexit: bool = False,
pipefail: bool = False,
Expand All @@ -71,6 +75,7 @@ def __init__(
env: Additional environment variables.
limits: Execution limits for security.
network: Network configuration (for curl command).
fetch: Custom secure fetch function (for curl command).
commands: Custom command registry. If not provided, uses built-in commands.
errexit: Enable errexit (set -e) mode.
pipefail: Enable pipefail mode.
Expand All @@ -88,11 +93,18 @@ def __init__(
# Set up limits
self._limits = limits or ExecutionLimits()

# Set up commands
self._commands = commands or create_command_registry()

# Set up network config
self._network = network
self._fetch = fetch or (make_default_fetch(network) if network is not None else None)

# Set up commands
if commands is None:
self._commands = create_command_registry(include_network=self._fetch is not None)
else:
self._commands = dict(commands)
if self._fetch is not None and "curl" not in self._commands:
for cmd in create_network_lazy_commands():
self._commands[cmd.name] = cmd

# Set up HTML unescaping
self._unescape_html = unescape_html
Expand Down Expand Up @@ -135,6 +147,7 @@ def __init__(
commands=self._commands,
limits=self._limits,
state=self._initial_state,
fetch=self._fetch,
)

@property
Expand Down Expand Up @@ -244,6 +257,7 @@ def reset(self) -> None:
fs=self._fs,
commands=self._commands,
limits=self._limits,
fetch=self._fetch,
state=InterpreterState(
env=self._initial_state.env.copy() if isinstance(self._initial_state.env, VariableStore) else VariableStore(self._initial_state.env),
cwd=self._initial_state.cwd,
Expand Down
9 changes: 8 additions & 1 deletion src/just_bash/commands/curl/curl.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ def format_headers(headers: dict[str, str]) -> str:
return "\r\n".join(f"{name}: {value}" for name, value in headers.items())


def body_to_stdout(body: str | bytes) -> str:
"""Convert response bytes to stdout's string representation."""
if isinstance(body, bytes):
return body.decode("latin-1")
return body


def extract_filename(url: str) -> str:
"""Extract filename from URL for -O option."""
try:
Expand Down Expand Up @@ -776,7 +783,7 @@ def _build_output(

# Add body (unless head-only mode)
if not options.head_only:
output += body
output += body_to_stdout(body)
elif options.include_headers or options.verbose:
# For HEAD, we already showed headers
pass
Expand Down
8 changes: 7 additions & 1 deletion src/just_bash/interpreter/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
ConditionalCommandNode,
ArithmeticCommandNode,
)
from ..types import Command, ExecResult, ExecutionLimits, IFileSystem
from ..types import Command, ExecResult, ExecutionLimits, IFileSystem, SecureFetch
from .errors import (
BadSubstitutionError,
BreakError,
Expand Down Expand Up @@ -129,6 +129,7 @@ def __init__(
commands: dict[str, Command],
limits: ExecutionLimits,
state: Optional[InterpreterState] = None,
fetch: Optional[SecureFetch] = None,
):
"""Initialize the interpreter.

Expand All @@ -137,10 +138,12 @@ def __init__(
commands: Command registry
limits: Execution limits
state: Optional initial state (creates default if not provided)
fetch: Optional secure fetch function for network-enabled commands
"""
self._fs = fs
self._commands = commands
self._limits = limits
self._fetch = fetch
self._state = state or InterpreterState(
env=VariableStore({
"PATH": "/usr/local/bin:/usr/bin:/bin",
Expand Down Expand Up @@ -216,6 +219,7 @@ async def _exec_fn(
commands=self._commands,
limits=self._limits,
state=new_state,
fetch=self._fetch,
)
try:
return await sub_interpreter.execute_script(ast)
Expand Down Expand Up @@ -570,6 +574,7 @@ async def _execute_subshell(self, node: SubshellNode, stdin: str) -> ExecResult:
commands=self._commands,
limits=self._limits,
state=new_state,
fetch=self._fetch,
)

# Execute statements in subshell
Expand Down Expand Up @@ -1158,6 +1163,7 @@ async def _execute_simple_command(
script, opts.get("env"), opts["cwd"]
),
get_registered_commands=lambda: list(self._commands.keys()),
fetch=self._fetch,
fd_contents=fd_contents,
)
result = await cmd.execute(args, ctx)
Expand Down
Loading