Skip to content
Closed
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
39 changes: 39 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Coverage

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
coverage:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
lfs: true

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: Run coverage
run: pnpm --filter just-bash test:coverage:unit

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: packages/just-bash/coverage/
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

just-bash is a TypeScript implementation of a bash interpreter with an in-memory virtual filesystem. Designed for AI agents needing a secure, sandboxed bash environment. No WASM dependencies allowed.
just-bash is a TypeScript implementation of a bash interpreter with an in-memory virtual filesystem. Designed for AI agents needing a secure, sandboxed bash environment. No new WASM dependencies without approval; approved exceptions are sql.js (SQLite), quickjs-emscripten (js-exec), and the vendored CPython WASM (python3).

## Commands

Expand Down Expand Up @@ -257,6 +257,6 @@ Object.setPrototypeOf(MAP, null);
- Always verify with `pnpm typecheck && pnpm lint:fix && pnpm knip && pnpm test:run` before finishing
- Assert full stdout/stderr in tests, not partial matches
- Implementation must match real bash behavior, not convenience
- Dependencies using WASM are not allowed (exception: sql.js for SQLite, approved for security sandboxing)
- No new WASM dependencies without approval. Approved exceptions: sql.js (SQLite), quickjs-emscripten (js-exec), and the vendored CPython WASM (python3); see THREAT_MODEL.md §4.7
- We explicitly don't support 64-bit integers
- All parsing/execution must have reasonable limits to prevent runaway compute
36 changes: 35 additions & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ The following components are **trusted** and outside the scope of just-bash's ru
| WeakRef/FinalizationRegistry | GC observation/side channels | Blocked by defense-in-depth proxy | `src/security/blocked-globals.ts` |
| process.chdir() | Confuse CWD tracking | Blocked by defense-in-depth proxy | `src/security/blocked-globals.ts` |
| **dynamic import()** | `import('/tmp/evil.js')` | **BLOCKED**: `Module._resolveFilename` blocks file specifiers; ESM loader hooks block `data:`/`blob:` URLs (Node.js 20.6+; see §4.1) | `src/security/defense-in-depth-box.ts` |
| child_process | spawn/exec/fork | Not imported anywhere; no code path from interpreter | Architecture |
| child_process | spawn/exec/fork | Not imported anywhere; no code path from interpreter. **With `javascript` enabled**, a *virtual* `child_process` module exists *inside* QuickJS (`js-exec-worker.ts:261–280`) whose `execSync`/`spawnSync` re-enter the sandbox via the SAB bridge, not the host OS — see §4.11 | Architecture (+ virtual shim, §4.11) |

### 3.6 Information Disclosure

Expand Down Expand Up @@ -249,6 +249,8 @@ If any code captures a reference to `Function`, `eval`, etc. **before** the defe

**Mitigation**: Defense-in-depth is a secondary layer. The primary defense is that no code path exists from bash interpretation to JavaScript execution.

**Architectural invariant**: §4.2 and §4.3 both depend on a single invariant — **no bash→host-JS code path**: there must be no route by which an untrusted bash script can reach the host's `eval`, `new Function`, or dynamic `import()`, the only primitives that could turn these defense-in-depth gaps into a real escape. While that invariant holds, §4.2 and §4.3 stay LOW (defense-in-depth is secondary). If the invariant ever breaks, these residuals become **CRITICAL** — a pre-captured `Function` reference (§4.2) or a `globalThis.Function` reassignment (§4.3) would then be a direct JS-execution escape. The invariant is **guarded by the `check-banned-patterns` linter** (`scripts/check-banned-patterns.js`), which bans `eval(`, `new Function(`, and non-literal dynamic `import()` in source, so a path to host-JS code execution cannot be introduced silently. See §4.1 (three-layer `import()` mitigation) and §4.11 (the opt-in js-exec surface, the *only* intentional guest-JS execution path, which keeps the guest off the host's `eval`/`Function`).

### 4.3 globalThis Property Reassignment

**Risk**: LOW (defense-in-depth is secondary)
Expand All @@ -257,6 +259,8 @@ Attackers within the sandbox could overwrite `globalThis.Function` or use `Objec

**Mitigation**: Same as §4.2 — relies on no code path existing, not on the monkey-patching being unbypassable.

**Architectural invariant**: See §4.2 — relies on the same no-bash→host-JS-code-path invariant, which is guarded by the `check-banned-patterns` linter (`scripts/check-banned-patterns.js` bans `eval(`, `new Function(`, non-literal `import()`). Becomes **CRITICAL** if that invariant ever breaks.

### 4.4 Signal/Job Control Not Fully Modeled

**Risk**: LOW
Expand Down Expand Up @@ -328,6 +332,36 @@ Heredocs with variable expansion are size-limited (10MB) but nested heredocs wit

`Reflect` is frozen (not blocked) in the defense-in-depth layer. `Reflect.construct`, `Reflect.apply` etc. remain callable but cannot construct `Function` directly because the `Function` constructor itself is blocked.

### 4.11 JavaScript Execution Surface (When Enabled)

**Risk**: MEDIUM (intentional, opt-in, isolation by construction)

When `javascript: true` — or an `invokeTool` hook is provided, which implicitly enables js-exec (`src/Bash.ts`) — the `js-exec` and `node` commands execute untrusted JavaScript/TypeScript inside QuickJS (compiled to WASM via Emscripten) in a dedicated Worker thread. The `node` command is a stub that reroutes to js-exec; both surface the same boundary. Like Python (§4.7), this is an opt-in code-execution surface whose safety rests on isolation by construction rather than on a JS-level sandbox over the host.

**Enabling the surface**: js-exec is registered only when `options.javascript || jsConfig.invokeTool` (`src/Bash.ts`). It is absent from the command registry and unreachable from any bash script unless the host explicitly turns it on. The `invokeTool` hook enables js-exec implicitly because the hook is meaningless without it.

**Host bridge (SharedArrayBuffer)**: User code runs inside QuickJS, which has no Node.js APIs of its own. A synchronous SharedArrayBuffer protocol (`src/commands/worker-bridge/protocol.ts`) bridges selected host capabilities back into the guest:
- **Virtual filesystem** — file read/write/stat are routed through the bridge to the in-memory OverlayFS, the same VFS the interpreter uses; no host-FS access.
- **`exec`** — `bridge-handler.ts` calls the host `exec` callback, which is `Bash.exec.bind(this)` (`src/Bash.ts`), i.e. it **re-enters the sandbox interpreter**, not the OS. Output is capped to the remaining execution deadline.
- **`fetch`** — Web Fetch API, gated by the host `secureFetch` allow-list (off by default).
- **`invokeTool`** — host-supplied tool hook for agent-driven tool calls; absent unless the host provides it.

**Virtual `child_process` shim**: Inside QuickJS, `import "child_process"` resolves to a **virtual module** (`js-exec-worker.ts:261–280`), not Node's `child_process`. Its `execSync`/`exec`/`spawnSync` route through `globalThis[Symbol.for('jb:exec')]` → the bridge → `Bash.exec`, so they re-enter the sandbox (no `spawn`/`fork`/OS process). This is the shim referenced from §3.5. Re-entrant js-exec is detected via AsyncLocalStorage and rejected to prevent deadlock ("recursive invocation is not supported").

**Isolation rests on**:
- **QuickJS WASM** — untrusted JS executes in the QuickJS interpreter within WASM linear memory; the guest has no access to Node.js host objects, only the four explicitly bridged primitives above.
- **Worker thread** — a dedicated worker per execution; terminated on timeout (`worker.terminate()`) and recycled after idle.
- **`WorkerDefenseInDepth`** — activated after QuickJS loads with only `shared_array_buffer`, `atomics`, `process_stdout`, `process_stderr` excluded (SAB/Atomics are required by the sync bridge; stdout/stderr because Emscripten routes WASM output through Node's console).
- **Memory cap** — QuickJS memory limited to 64MB (`MEMORY_LIMIT`, `js-exec-worker.ts`).
- **Timeout** — 10s default, 60s when network/fetch is enabled (`DEFAULT_JS_TIMEOUT_MS` / `DEFAULT_JS_NETWORK_TIMEOUT_MS`, `js-exec.ts`); enforced by terminating the worker.

**Accepted behaviors** (not vulnerabilities):
- `eval()`/`new Function()` inside QuickJS execute arbitrary *guest* JS — same posture as Python's `eval()`/`exec()` (§4.7); no host-JS escalation path because the guest cannot reach the host's `eval`/`Function`.
- The bridge `exec` re-enters the sandbox, so any command reachable from js-exec is one the host already permitted in the bash environment; it does not widen the command surface beyond what bash itself can reach.
- TS source is transpiled to JS before being handed to QuickJS; this is a guest-side convenience, not a host code path.

**Residual risk**: The security of this surface depends on the QuickJS/WASM boundary holding — i.e. the guest genuinely cannot reach host-JS primitives except through the four bridged calls above. The escape vectors to watch are a QuickJS/WASM breakout, or a bridge-call handler that acts on attacker-controlled arguments without validation (path validation in the FS bridge is delegated to the OverlayFs gates; `exec` re-enters the interpreter, which applies its own limits). Severity is **MEDIUM**, analogous to Python (§4.7): opt-in, isolated by construction, bounded by memory + timeout, and only as trustworthy as the WASM boundary it rests on. This surface is also the one residual that can weaken the no-bash→host-JS-code-path invariant of §4.2/§4.3 — but only if the WASM boundary is broken, since the guest is intentionally kept off the host's `eval`/`Function`/`import()`.

---

## 5. Defense Layer Summary
Expand Down
4 changes: 2 additions & 2 deletions packages/just-bash/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Custom commands receive a `CommandContext` with `fs`, `cwd`, `env`, `stdin`, and

### Optional Runtimes

`js-exec` (JavaScript/TypeScript via QuickJS; requires `javascript: true`), `python3`/`python` (Python via CPython; requires `python: true`)
`js-exec` (JavaScript/TypeScript via QuickJS; requires `javascript: true`), `python3`/`python` (Python via CPython; requires `python: true`). The `node` command is an alias of the `js-exec` runtime, not a real Node.js process.

### Compression & Archives

Expand Down Expand Up @@ -594,7 +594,7 @@ All limits have defaults. Error messages tell you which limit was hit. Increase

## Browser Support

The core shell (parsing, execution, filesystem, and all built-in commands) works in browser environments. The following features require Node.js and are unavailable in browsers: `python3`/`python`, `sqlite3`, `js-exec`, and `OverlayFs`/`ReadWriteFs` (which access the real filesystem).
The core shell (parsing, execution, filesystem, and all built-in commands) works in browser environments. The following features require Node.js and are unavailable in browsers: `python3`/`python`, `sqlite3`, `js-exec`/`node`, `tar`, `yq`, `xan`, and `OverlayFs`/`ReadWriteFs` (which access the real filesystem).

## Default Layout

Expand Down