@jerryan/just-bash 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +334 -308
  2. package/LICENSE +201 -201
  3. package/README.md +827 -827
  4. package/dist/agent-sandbox.d.ts +27 -0
  5. package/dist/bin/chunks/{chunk-7WBCA2BB.js → chunk-3DWQCLNP.js} +1 -1
  6. package/dist/bin/chunks/{chunk-QFTZKD5O.js → chunk-6TUILMPB.js} +1 -1
  7. package/dist/bin/chunks/chunk-JHEAT7AR.js +12 -0
  8. package/dist/bin/chunks/{chunk-MF3GVFLC.js → chunk-L345YZ6R.js} +1 -1
  9. package/dist/bin/chunks/chunk-QKJAAJ7J.js +3 -0
  10. package/dist/bin/chunks/chunk-SE7JHGP3.js +9 -0
  11. package/dist/bin/{shell/chunks/chunk-NLU5LALY.js → chunks/chunk-YCOJCFGU.js} +1 -1
  12. package/dist/bin/{shell/chunks/flag-coverage-J2ZHA465.js → chunks/flag-coverage-FYJ2V7J4.js} +1 -1
  13. package/dist/bin/chunks/{js-exec-DJQLXWCS.js → js-exec-5QZZCOR7.js} +7 -7
  14. package/dist/bin/chunks/js-exec-worker.js +155 -33
  15. package/dist/bin/{shell/chunks/python3-JCJHSX4Z.js → chunks/python3-TMFYDQAP.js} +1 -1
  16. package/dist/bin/chunks/{rm-3PLW4O4B.js → rm-NLG5344W.js} +1 -1
  17. package/dist/bin/chunks/{sleep-DVFE2GSF.js → sleep-MOJQBZMY.js} +1 -1
  18. package/dist/bin/{shell/chunks/sqlite3-KP42NNIY.js → chunks/sqlite3-3EUPHVEZ.js} +1 -1
  19. package/dist/bin/chunks/{timeout-YGFHQJKX.js → timeout-RQOWSWYG.js} +1 -1
  20. package/dist/bin/chunks/worker.js +162 -48
  21. package/dist/bin/just-bash.js +307 -307
  22. package/dist/bin/shell/chunks/{chunk-7WBCA2BB.js → chunk-3DWQCLNP.js} +1 -1
  23. package/dist/bin/shell/chunks/{chunk-QFTZKD5O.js → chunk-6TUILMPB.js} +1 -1
  24. package/dist/bin/shell/chunks/chunk-JHEAT7AR.js +12 -0
  25. package/dist/bin/shell/chunks/{chunk-MF3GVFLC.js → chunk-L345YZ6R.js} +1 -1
  26. package/dist/bin/shell/chunks/chunk-QKJAAJ7J.js +3 -0
  27. package/dist/bin/shell/chunks/chunk-SE7JHGP3.js +9 -0
  28. package/dist/bin/{chunks/chunk-NLU5LALY.js → shell/chunks/chunk-YCOJCFGU.js} +1 -1
  29. package/dist/bin/{chunks/flag-coverage-J2ZHA465.js → shell/chunks/flag-coverage-FYJ2V7J4.js} +1 -1
  30. package/dist/bin/shell/chunks/{js-exec-DJQLXWCS.js → js-exec-5QZZCOR7.js} +7 -7
  31. package/dist/bin/{chunks/python3-JCJHSX4Z.js → shell/chunks/python3-TMFYDQAP.js} +1 -1
  32. package/dist/bin/shell/chunks/{rm-3PLW4O4B.js → rm-NLG5344W.js} +1 -1
  33. package/dist/bin/shell/chunks/{sleep-DVFE2GSF.js → sleep-MOJQBZMY.js} +1 -1
  34. package/dist/bin/{chunks/sqlite3-KP42NNIY.js → shell/chunks/sqlite3-3EUPHVEZ.js} +1 -1
  35. package/dist/bin/shell/chunks/{timeout-YGFHQJKX.js → timeout-RQOWSWYG.js} +1 -1
  36. package/dist/bin/shell/shell.js +237 -237
  37. package/dist/bundle/browser.js +240 -240
  38. package/dist/bundle/chunks/{chunk-N3A4JVFJ.js → chunk-6IA4DXG2.js} +1 -1
  39. package/dist/bundle/chunks/chunk-G3IRZ4FN.js +8 -0
  40. package/dist/bundle/chunks/{chunk-24NLAR7R.js → chunk-KEMO3CBM.js} +1 -1
  41. package/dist/bundle/chunks/chunk-PS2RJNZZ.js +2 -0
  42. package/dist/bundle/chunks/{chunk-DMWNBCXJ.js → chunk-TLPMB3FO.js} +1 -1
  43. package/dist/bundle/chunks/chunk-ULXGGWAD.js +11 -0
  44. package/dist/bundle/chunks/{chunk-XV2V56NQ.js → chunk-YFFU74KY.js} +1 -1
  45. package/dist/bundle/chunks/{flag-coverage-DPSAPFXA.js → flag-coverage-XSPWRCVG.js} +1 -1
  46. package/dist/bundle/chunks/{js-exec-XKF2B7C3.js → js-exec-57QMXJG5.js} +7 -7
  47. package/dist/bundle/chunks/js-exec-worker.js +155 -33
  48. package/dist/bundle/chunks/{python3-F5S5TYGS.js → python3-NNONJINM.js} +1 -1
  49. package/dist/bundle/chunks/{rm-JQ4GDKDD.js → rm-R2ZENNSN.js} +1 -1
  50. package/dist/bundle/chunks/{sleep-PPRBTSVR.js → sleep-25QUPVDK.js} +1 -1
  51. package/dist/bundle/chunks/{sqlite3-4EZ4GXVD.js → sqlite3-M4UZPXDO.js} +1 -1
  52. package/dist/bundle/chunks/{timeout-C6WDYPLV.js → timeout-37GM7IIL.js} +1 -1
  53. package/dist/bundle/chunks/worker.js +162 -48
  54. package/dist/bundle/index.cjs +760 -760
  55. package/dist/bundle/index.js +306 -306
  56. package/dist/commands/js-exec/js-exec.d.ts +4 -0
  57. package/dist/commands/worker-bridge/bridge-handler.d.ts +9 -0
  58. package/dist/commands/worker-bridge/protocol.d.ts +17 -0
  59. package/dist/commands/worker-bridge/sync-backend.d.ts +3 -0
  60. package/dist/fs/overlay-fs/apply.d.ts +13 -0
  61. package/dist/fs/overlay-fs/index.d.ts +2 -0
  62. package/dist/fs/overlay-fs/merge.d.ts +24 -0
  63. package/dist/fs/overlay-fs/overlay-fs.d.ts +17 -0
  64. package/dist/fs/overlay-fs/overlay-tree.d.ts +39 -0
  65. package/dist/fs/real-fs-utils.d.ts +15 -0
  66. package/dist/fs/vfs-template.d.ts +51 -0
  67. package/dist/index.d.cts +2 -1
  68. package/dist/index.d.ts +2 -1
  69. package/dist/timers.d.ts +9 -0
  70. package/package.json +32 -33
  71. package/vendor/cpython-emscripten/python.cjs +2 -2
  72. package/dist/bin/chunks/chunk-JQM3DKCH.js +0 -12
  73. package/dist/bin/chunks/chunk-PPVF2N6C.js +0 -3
  74. package/dist/bin/chunks/chunk-YOIFOOGX.js +0 -9
  75. package/dist/bin/shell/chunks/chunk-JQM3DKCH.js +0 -12
  76. package/dist/bin/shell/chunks/chunk-PPVF2N6C.js +0 -3
  77. package/dist/bin/shell/chunks/chunk-YOIFOOGX.js +0 -9
  78. package/dist/bundle/chunks/chunk-LPJEKUCB.js +0 -8
  79. package/dist/bundle/chunks/chunk-WXPNKIIH.js +0 -2
  80. package/dist/bundle/chunks/chunk-YJKHREBZ.js +0 -11
package/README.md CHANGED
@@ -1,827 +1,827 @@
1
- # just-bash
2
-
3
- A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents.
4
-
5
- Broad support for standard unix commands and bash syntax with optional curl, Python, JS/TS, and sqlite support.
6
-
7
- **Note**: This is beta software. Use at your own risk and please provide feedback. See [security model](#security-model).
8
-
9
- ## Quick Start
10
-
11
- ```bash
12
- npm install @jerryan/just-bash
13
- ```
14
-
15
- ```typescript
16
- import { Bash } from "@jerryan/just-bash";
17
-
18
- const bash = new Bash();
19
- await bash.exec('echo "Hello" > greeting.txt');
20
- const result = await bash.exec("cat greeting.txt");
21
- console.log(result.stdout); // "Hello\n"
22
- console.log(result.exitCode); // 0
23
- ```
24
-
25
- Each `exec()` call gets its own isolated shell state — environment variables, functions, and working directory reset between calls. The **filesystem is shared** across calls, so files written in one `exec()` are visible in the next.
26
-
27
- ## Custom Commands
28
-
29
- Extend just-bash with your own TypeScript commands using `defineCommand`:
30
-
31
- ```typescript
32
- import { Bash, decodeBytesToUtf8, defineCommand } from "@jerryan/just-bash";
33
-
34
- const hello = defineCommand("hello", async (args, ctx) => {
35
- const name = args[0] || "world";
36
- return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 };
37
- });
38
-
39
- const upper = defineCommand("upper", async (args, ctx) => {
40
- // ctx.stdin is a ByteString — decode to text before string ops.
41
- return {
42
- stdout: decodeBytesToUtf8(ctx.stdin).toUpperCase(),
43
- stderr: "",
44
- exitCode: 0,
45
- };
46
- });
47
-
48
- const bash = new Bash({ customCommands: [hello, upper] });
49
-
50
- await bash.exec("hello Alice"); // "Hello, Alice!\n"
51
- await bash.exec("echo 'test' | upper"); // "TEST\n"
52
- ```
53
-
54
- Custom command callbacks receive a `ResolvedCommandContext` with `fs`, `cwd`,
55
- `env`, `stdin`, resolved `limits`, and `exec` (for subcommands), and work with
56
- pipes, redirections, and all shell features. The legacy `CommandContext` remains
57
- available for standalone context inputs; use `createCommandContext({ fs })` when
58
- calling a command directly with a fully resolved context.
59
-
60
- Host-provided commands preserve the legacy trusted default whether supplied to
61
- the `Bash` constructor, declared through `defineCommand`, loaded lazily, or
62
- added later with `bash.registerCommand()`. Set `trusted: false` (or use
63
- `defineCommand(name, execute, { trusted: false })`) to select the restricted
64
- extension boundary. Trusted commands run in the embedding process and should
65
- never execute guest-provided JavaScript.
66
-
67
- Every invocation is bound by `maxExecutionTimeMs`. On cancellation, just-bash
68
- revokes the command context immediately; `maxExtensionCleanupTimeMs` only
69
- bounds how long it waits for the now-authority-free command promise to settle.
70
- A late continuation cannot use `ctx.fs`, `ctx.env`, `ctx.exec`, or other context
71
- capabilities. Cleanup work that must run at scope closure can be registered with
72
- `ctx.executionScope.registerCleanup()`. A cleanup failure is returned as a
73
- generic exit-126 shell result rather than rejecting `Bash.exec()` or exposing
74
- host error details. JavaScript cannot forcibly stop arbitrary host code, so
75
- extensions requiring a hard guarantee against external side effects must run
76
- in a terminable worker or process. Tests that invoke command objects directly
77
- can use `createCommandContext({ fs })` to get a fully resolved context without
78
- duplicating internal defaults.
79
-
80
- <details>
81
- <summary><h2>Supported Commands</h2></summary>
82
-
83
- ### File Operations
84
-
85
- `cat`, `cp`, `file`, `ln`, `ls`, `mkdir`, `mv`, `readlink`, `rm`, `rmdir`, `split`, `stat`, `touch`, `tree`
86
-
87
- ### Text Processing
88
-
89
- `awk`, `base64`, `column`, `comm`, `cut`, `diff`, `expand`, `fold`, `grep` (+ `egrep`, `fgrep`), `head`, `join`, `md5sum`, `nl`, `od`, `paste`, `printf`, `rev`, `rg`, `sed`, `sha1sum`, `sha256sum`, `sort`, `strings`, `tac`, `tail`, `tr`, `unexpand`, `uniq`, `wc`, `xargs`
90
-
91
- ### Data Processing
92
-
93
- `jq` (JSON), `sqlite3` (SQLite), `xan` (CSV), `yq` (YAML/XML/TOML/CSV)
94
-
95
- ### Optional Runtimes
96
-
97
- `js-exec` (JavaScript/TypeScript via QuickJS; requires `javascript: true`), `python3`/`python` (Python via CPython; requires `python: true`)
98
-
99
- ### Compression & Archives
100
-
101
- `gzip` (+ `gunzip`, `zcat`), `tar`
102
-
103
- ### Navigation & Environment
104
-
105
- `basename`, `cd`, `dirname`, `du`, `echo`, `env`, `export`, `find`, `hostname`, `printenv`, `pwd`, `tee`
106
-
107
- ### Shell Utilities
108
-
109
- `alias`, `bash`, `chmod`, `clear`, `date`, `expr`, `false`, `help`, `history`, `seq`, `sh`, `sleep`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami`
110
-
111
- ### Network
112
-
113
- `curl`, `html-to-markdown` (require [network configuration](#network-access))
114
-
115
- All commands support `--help` for usage information.
116
-
117
- ### Shell Features
118
-
119
- - **Pipes**: `cmd1 | cmd2`
120
- - **Redirections**: `>`, `>>`, `2>`, `2>&1`, `<`
121
- - **Command chaining**: `&&`, `||`, `;`
122
- - **Variables**: `$VAR`, `${VAR}`, `${VAR:-default}`
123
- - **Positional parameters**: `$1`, `$2`, `$@`, `$#`
124
- - **Glob patterns**: `*`, `?`, `[...]`
125
- - **If statements**: `if COND; then CMD; elif COND; then CMD; else CMD; fi`
126
- - **Functions**: `function name { ... }` or `name() { ... }`
127
- - **Local variables**: `local VAR=value`
128
- - **Loops**: `for`, `while`, `until`
129
- - **Symbolic links**: `ln -s target link`
130
- - **Hard links**: `ln target link`
131
-
132
- </details>
133
-
134
- ## Configuration
135
-
136
- ```typescript
137
- const env = new Bash({
138
- files: { "/data/file.txt": "content" }, // Initial files
139
- env: { MY_VAR: "value" }, // Initial environment
140
- cwd: "/app", // Starting directory (default: /home/user)
141
- executionLimits: { maxCallDepth: 50 }, // See "Execution Protection"
142
- python: true, // Enable python3/python commands
143
- javascript: true, // Enable js-exec command
144
- // Or with bootstrap: javascript: { bootstrap: "globalThis.X = 1;" }
145
- });
146
-
147
- // Per-exec overrides
148
- await env.exec("echo $TEMP", { env: { TEMP: "value" }, cwd: "/tmp" });
149
-
150
- // Pass stdin to the script
151
- await env.exec("cat", { stdin: "hello from stdin\n" });
152
-
153
- // Start with a clean environment
154
- await env.exec("env", { replaceEnv: true, env: { ONLY: "this" } });
155
-
156
- // Pass arguments without shell escaping (like spawnSync)
157
- await env.exec("grep", { args: ["-r", "TODO", "src/"] });
158
-
159
- // Cancel long-running scripts
160
- const controller = new AbortController();
161
- setTimeout(() => controller.abort(), 5000);
162
- await env.exec("while true; do sleep 1; done", { signal: controller.signal });
163
-
164
- // Preserve leading whitespace (e.g., for heredocs)
165
- await env.exec("cat <<EOF\n indented\nEOF", { rawScript: true });
166
- ```
167
-
168
- ### Timezone
169
-
170
- `date` defaults to UTC (`%Z=UTC`, `%z=+0000`) regardless of the host clock, so the sandbox does not leak the host timezone. To opt into a specific zone, pass `TZ` as an initial env var:
171
-
172
- ```typescript
173
- const bash = new Bash({ env: { TZ: "America/New_York" } });
174
- await bash.exec("date"); // Mon Jun 1 09:30:00 EDT 2026
175
- ```
176
-
177
- `-u` always forces UTC; an unset or invalid `$TZ` falls back to UTC. Setting `TZ` exposes that timezone to scripts running in the sandbox, so only pass a value you are comfortable revealing — forwarding the host's real `$TZ` (e.g. `process.env.TZ`) reintroduces the disclosure that the UTC default exists to prevent.
178
-
179
- `exec()` options:
180
-
181
- | Option | Type | Description |
182
- |---|---|---|
183
- | `env` | `Record<string, string>` | Environment variables for this execution only |
184
- | `cwd` | `string` | Working directory for this execution only |
185
- | `stdin` | `string` | Standard input passed to the script |
186
- | `args` | `string[]` | Additional argv passed directly to the first command (bypasses shell parsing; does not change `$1`, `$2`, ...) |
187
- | `replaceEnv` | `boolean` | Start with empty env instead of merging (default: `false`) |
188
- | `signal` | `AbortSignal` | Cooperative cancellation; stops at next statement boundary |
189
- | `rawScript` | `boolean` | Skip leading-whitespace normalization (default: `false`) |
190
-
191
- ## Filesystem Options
192
-
193
- Four filesystem implementations:
194
-
195
- **InMemoryFs** (default) - Pure in-memory filesystem, no disk access:
196
-
197
- ```typescript
198
- import { Bash } from "@jerryan/just-bash";
199
-
200
- const env = new Bash({
201
- files: {
202
- "/data/config.json": '{"key": "value"}',
203
- // Lazy: called on first read, cached. Never called if written before read.
204
- "/data/large.csv": () => "col1,col2\na,b\n",
205
- "/data/remote.txt": async () => (await fetch("https://example.com")).text(),
206
- },
207
- });
208
- ```
209
-
210
- **OverlayFs** - Copy-on-write over a real directory. Reads come from disk, writes stay in memory:
211
-
212
- ```typescript
213
- import { Bash } from "@jerryan/just-bash";
214
- import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
215
-
216
- const overlay = new OverlayFs({
217
- root: "/path/to/project",
218
- // Copy-on-write data is bounded independently from real-file reads.
219
- maxMemoryBytes: 256 * 1024 * 1024,
220
- });
221
- const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() });
222
-
223
- await env.exec("cat package.json"); // reads from disk
224
- await env.exec('echo "modified" > package.json'); // stays in memory
225
- ```
226
-
227
- `maxMemoryBytes` defaults to 1 GiB and covers aggregate files retained in the
228
- copy-on-write layer, including append chunks. Set it to the deployment's memory
229
- budget when an `OverlayFs` is reused across executions.
230
-
231
- **Change sets** — `diff()`, `sync()`, and `reset()` turn the overlay into a
232
- reviewable sandbox: the agent writes only to memory, the host applies accepted
233
- changes, and `sync()` drops whatever now matches disk (rejected changes stay
234
- pending; `reset()` discards everything):
235
-
236
- ```typescript
237
- import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
238
-
239
- const overlay = new OverlayFs({ root: "/path/to/project" });
240
- // ... run agent commands against the overlay ...
241
-
242
- const diff = overlay.diff();
243
- for (const write of diff.writes) {
244
- // { path, nodeType, content, mode, mtime, metadataOnly? }
245
- console.log("write", write.path);
246
- }
247
- for (const rel of diff.deletions) {
248
- console.log("delete", rel);
249
- }
250
- // Host applies what it accepts to the real directory, then:
251
- await overlay.sync(); // applied changes drop out of the pending set
252
- ```
253
-
254
- **`createAgentSandbox`** bundles the whole setup for the common agent case —
255
- InMemoryFs root, overlays over the real home and project directories, combined
256
- change sets with real absolute paths, and `applyChanges()` (apply + drop
257
- in one call):
258
-
259
- ```typescript
260
- import { createAgentSandbox } from "@jerryan/just-bash";
261
-
262
- const sandbox = createAgentSandbox({
263
- home: "/real/home",
264
- project: "/real/project",
265
- abortOnUnresolvedCommands: true,
266
- });
267
- const result = await sandbox.exec("echo hi > notes.txt");
268
- const changes = sandbox.diff(); // real paths
269
- await sandbox.applyChanges(changes); // host applies; applied entries drop
270
- ```
271
-
272
- See the [agent sandbox integration recipe](../../docs/recipes/agent-sandbox-integration.md)
273
- and `examples/agent-sandbox.mjs` for the full per-turn loop (static pre-flight,
274
- sandboxed exec, host-side apply), including the out-of-band modification policy
275
- you must read before pointing this at a live project.
276
-
277
- **ReadWriteFs** - Direct read-write access to a real directory. Use this if you want the agent to be able to write to your disk:
278
-
279
- ```typescript
280
- import { Bash } from "@jerryan/just-bash";
281
- import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
282
-
283
- const rwfs = new ReadWriteFs({ root: "/path/to/sandbox" });
284
- const env = new Bash({ fs: rwfs });
285
-
286
- await env.exec('echo "hello" > file.txt'); // writes to real filesystem
287
- ```
288
-
289
- Keep `ReadWriteFs` pointed at a workspace directory, not at the installed `just-bash` package or any other trusted runtime code. Guest-writable roots should stay separate from trusted code.
290
-
291
- `ReadWriteFs` uses normal in-place filesystem operations for private regular
292
- files. For multiply-linked regular files, it isolates append and metadata
293
- changes by copying the file and replacing only the sandbox directory entry, so
294
- a host-created hard link cannot carry those changes beyond the configured root.
295
- Implicit copies are limited by `maxCopyOnWriteSize` (100 MB by default; set it
296
- to `0` to disable the limit). Overwrite does not need to copy existing content.
297
- Explicit `cp` copies can be limited with the opt-in `maxCopySize` option
298
- (unlimited by default). The portable copy path may materialize sparse-file
299
- holes, so embeddings that require a disk-allocation bound should configure
300
- `maxCopySize`.
301
-
302
- Shared-inode isolation has a few deliberate limitations:
303
-
304
- - Append, `chmod`, and `utimes` on a multiply-linked regular file require read
305
- access to the file and write access to its parent directory. They fail with
306
- `EFBIG` when the file exceeds `maxCopyOnWriteSize`.
307
- - Copies use `O_NOATIME` when the Node.js runtime exposes it and retry with
308
- normal read semantics if the kernel returns `EPERM`. Runtimes and platforms
309
- without `O_NOATIME` may update access-time metadata visible through another
310
- hard link.
311
- - Do not mutate a `ReadWriteFs` root concurrently through direct host filesystem
312
- APIs. Node.js does not expose the descriptor-relative operations needed to
313
- make pathname validation atomic against an external actor. A concurrent host
314
- append to a multiply-linked file may be lost when the isolated entry is
315
- replaced.
316
- - Mutations in overlapping `ReadWriteFs` roots are serialized within the
317
- process. Unrelated roots proceed independently. The queue is not cancellable
318
- or bounded, so a large mutation can delay later operations in overlapping
319
- roots even if the requesting script is subsequently aborted.
320
- - Content writes and appends to FIFOs, sockets, devices, and other special files
321
- are rejected. This avoids indefinitely occupying an overlapping-root mutation
322
- slot on a blocking special-file open. Metadata operations remain supported
323
- for single-link special files; multiply-linked special files are rejected
324
- because they cannot be isolated without changing their file type.
325
- - Private-file and single-link special-file metadata operations use pathname
326
- APIs to preserve normal host permission semantics. They are not atomic
327
- against a trusted host actor concurrently replacing that pathname with a
328
- symlink. With `allowSymlinks: false`, symlinks present during normal path
329
- validation are still rejected.
330
- - Copying a symlink preserves whether its guest target is absolute or relative.
331
- Only symlinks whose resolved targets remain inside the root are copied.
332
- - Regular-file copies replace the destination entry to prevent writes through
333
- hard links. Existing destinations must still be writable, and their parent
334
- directory must be writable so the isolated entry can be committed. Thus a
335
- writable destination in a non-writable directory cannot be copied over.
336
- Copying over a FIFO, socket, device, or other special entry is rejected.
337
-
338
- **MountableFs** - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace:
339
-
340
- ```typescript
341
- import { Bash, MountableFs, InMemoryFs } from "@jerryan/just-bash";
342
- import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
343
- import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
344
-
345
- const fs = new MountableFs({ base: new InMemoryFs() });
346
-
347
- // Mount read-only knowledge base
348
- fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true }));
349
-
350
- // Mount read-write workspace
351
- fs.mount("/home/agent", new ReadWriteFs({ root: "/path/to/workspace" }));
352
-
353
- const bash = new Bash({ fs, cwd: "/home/agent" });
354
-
355
- await bash.exec("ls /mnt/knowledge"); // reads from knowledge base
356
- await bash.exec("cp /mnt/knowledge/doc.txt ./"); // cross-mount copy
357
- await bash.exec('echo "notes" > notes.txt'); // writes to workspace
358
- ```
359
-
360
- You can also configure mounts in the constructor:
361
-
362
- ```typescript
363
- import { MountableFs, InMemoryFs } from "@jerryan/just-bash";
364
- import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
365
- import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
366
-
367
- const fs = new MountableFs({
368
- base: new InMemoryFs(),
369
- mounts: [
370
- { mountPoint: "/data", filesystem: new OverlayFs({ root: "/shared/data" }) },
371
- { mountPoint: "/workspace", filesystem: new ReadWriteFs({ root: "/tmp/work" }) },
372
- ],
373
- });
374
- ```
375
-
376
- ## Optional Capabilities
377
-
378
- ### Network Access
379
-
380
- Network access is disabled by default. Enable it with the `network` option:
381
-
382
- ```typescript
383
- // Allow specific URLs with GET/HEAD only (safest)
384
- const env = new Bash({
385
- network: {
386
- allowedUrlPrefixes: [
387
- "https://api.github.com/repos/myorg/",
388
- "https://api.example.com",
389
- ],
390
- },
391
- });
392
-
393
- // Allow specific URLs with additional methods
394
- const env = new Bash({
395
- network: {
396
- allowedUrlPrefixes: ["https://api.example.com"],
397
- allowedMethods: ["GET", "HEAD", "POST"], // Default: ["GET", "HEAD"]
398
- },
399
- });
400
-
401
- // Inject credentials via header transforms (secrets never enter the sandbox)
402
- const env = new Bash({
403
- network: {
404
- allowedUrlPrefixes: [
405
- "https://public-api.com", // plain string — no transforms
406
- {
407
- url: "https://ai-gateway.vercel.sh",
408
- transform: [{ headers: { Authorization: "Bearer secret" } }],
409
- },
410
- ],
411
- },
412
- });
413
-
414
- // Allow all URLs and methods (use with caution)
415
- const env = new Bash({
416
- network: { dangerouslyAllowFullInternetAccess: true },
417
- });
418
- ```
419
-
420
- **Note:** The `curl` command only exists when network is configured. Without network configuration, `curl` returns "command not found".
421
-
422
- #### Allow-List Security
423
-
424
- The allow-list enforces:
425
-
426
- - **Origin matching**: URLs must match the exact origin (scheme + host + port)
427
- - **Path prefix**: Only paths starting with the specified prefix are allowed
428
- - **HTTP method restrictions**: Only GET and HEAD by default (configure `allowedMethods` for more)
429
- - **Redirect protection**: Redirects to non-allowed URLs are blocked
430
- - **Header transforms**: Firewall headers are injected at the fetch boundary and override any user-supplied headers with the same name, preventing credential substitution from inside the sandbox. Headers are re-evaluated on each redirect so credentials are never leaked to non-transform hosts
431
-
432
- #### Using curl
433
-
434
- ```bash
435
- # Fetch and process data
436
- curl -s https://api.example.com/data | grep pattern
437
-
438
- # Download and convert HTML to Markdown
439
- curl -s https://example.com | html-to-markdown
440
-
441
- # POST JSON data
442
- curl -X POST -H "Content-Type: application/json" \
443
- -d '{"key":"value"}' https://api.example.com/endpoint
444
- ```
445
-
446
- ### JavaScript Support
447
-
448
- JavaScript and TypeScript execution via QuickJS is opt-in due to additional security surface. Enable with `javascript: true`:
449
-
450
- ```typescript
451
- const env = new Bash({
452
- javascript: true,
453
- });
454
-
455
- // Execute JavaScript code
456
- await env.exec('js-exec -c "console.log(1 + 2)"');
457
-
458
- // Run script files (.js, .mjs, .ts, .mts)
459
- await env.exec('js-exec script.js');
460
-
461
- // ES module mode with imports
462
- await env.exec('js-exec -m -c "import fs from \'fs\'; console.log(fs.readFileSync(\'/data/file.txt\', \'utf8\'))"');
463
- ```
464
-
465
- #### Bootstrap Code
466
-
467
- Run setup code before every `js-exec` invocation with the `bootstrap` option:
468
-
469
- ```typescript
470
- const env = new Bash({
471
- javascript: {
472
- bootstrap: `
473
- globalThis.API_BASE = "https://api.example.com";
474
- globalThis.formatDate = (d) => new Date(d).toISOString();
475
- `,
476
- },
477
- });
478
-
479
- await env.exec('js-exec -c "console.log(API_BASE)"');
480
- // Output: https://api.example.com
481
- ```
482
-
483
- #### Node.js Compatibility
484
-
485
- `js-exec` supports `require()` and `import` with these Node.js modules:
486
-
487
- - **fs**: `readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `rmSync`, `fs.promises.*`
488
- - **path**: `join`, `resolve`, `dirname`, `basename`, `extname`, `relative`, `normalize`
489
- - **child_process**: `execSync`, `spawnSync`
490
- - **process**: `argv`, `cwd()`, `exit()`, `env`, `platform`, `version`
491
- - **Other modules**: `os`, `url`, `assert`, `util`, `events`, `buffer`, `stream`, `string_decoder`, `querystring`
492
- - **Globals**: `console`, `fetch`, `Buffer`, `URL`, `URLSearchParams`
493
-
494
- `fs.readFileSync()` returns a `Buffer` by default (matching Node.js). Pass an encoding like `'utf8'` to get a string.
495
-
496
- **Note:** The `js-exec` command only exists when `javascript` is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (30 seconds in the default `normal` profile and 10 seconds in the opt-in `hardened` profile). Enabling network access does not extend the configured deadline.
497
-
498
- #### Tool Invocation Hook
499
-
500
- `js-exec` scripts can call host-defined tools through a global `tools` proxy
501
- when `javascript.invokeTool` is provided:
502
-
503
- ```typescript
504
- const bash = new Bash({
505
- javascript: {
506
- // path: "math.add" (dot-separated)
507
- // argsJson: '{"a":1,"b":2}' (or "" for no args)
508
- // return: JSON-stringified result, or "" for undefined
509
- // throw: propagates as a sandbox exception
510
- invokeTool: async (path, argsJson) => {
511
- const args = argsJson ? JSON.parse(argsJson) : undefined;
512
- if (path === "math.add") {
513
- return JSON.stringify({ sum: args.a + args.b });
514
- }
515
- throw new Error(`Unknown tool: ${path}`);
516
- },
517
- },
518
- });
519
-
520
- await bash.exec(`js-exec -c 'console.log((await tools.math.add({a:3,b:4})).sum)'`);
521
- ```
522
-
523
- The hook is generic — wire any tool framework through it (raw maps, MCP,
524
- Anthropic tool-use, etc.). For full GraphQL / OpenAPI / MCP discovery via
525
- `@executor-js/sdk`, plus auto-generated bash namespace commands, use the
526
- companion package
527
- [**`@just-bash/executor`**](../just-bash-executor/README.md).
528
-
529
- ### Python Support
530
-
531
- Python (CPython compiled to WASM) is opt-in due to additional security surface. Enable with `python: true`:
532
-
533
- ```typescript
534
- const env = new Bash({
535
- python: true,
536
- });
537
-
538
- // Execute Python code
539
- await env.exec('python3 -c "print(1 + 2)"');
540
-
541
- // Run Python scripts
542
- await env.exec('python3 script.py');
543
- ```
544
-
545
- **Note:** The `python3` and `python` commands only exist when `python: true` is configured. Python is not available in browser environments.
546
-
547
- ### SQLite Support
548
-
549
- `sqlite3` uses sql.js (SQLite compiled to WASM), sandboxed from the real filesystem:
550
-
551
- ```typescript
552
- const env = new Bash();
553
-
554
- // Query in-memory database
555
- await env.exec('sqlite3 :memory: "SELECT 1 + 1"');
556
-
557
- // Query file-based database
558
- await env.exec('sqlite3 data.db "SELECT * FROM users"');
559
- ```
560
-
561
- **Note:** SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (30 seconds in the default `normal` profile and 5 seconds in the opt-in `hardened` profile) to prevent runaway queries from blocking execution.
562
-
563
- ## Detecting Unsupported Commands
564
-
565
- Agent harnesses need to know when a script uses commands the sandbox doesn't implement — anything the sandbox can't run should be *visible* instead of silently wrong. Three layers, all opt-in except the first:
566
-
567
- **Runtime reporting (always on).** Every `exec()` result lists command names that failed resolution, captured from anywhere in the script — subshells, pipelines, command substitutions, nested `bash -c`:
568
-
569
- ```typescript
570
- const bash = new Bash();
571
- const result = await bash.exec("grep -r foo src/ && git status");
572
- result.unresolvedCommands; // ["git"]
573
- result.exitCode; // 0 — execution continued bash-style (the miss itself exited 127)
574
- ```
575
-
576
- **Static pre-flight.** `analyzeCommands()` parses a script and reports which literal command names would fail resolution, without executing anything or modifying state. Useful for skipping doomed scripts before they cause side effects:
577
-
578
- ```typescript
579
- const bash = new Bash();
580
- const analysis = await bash.analyzeCommands("grep -r foo src/ && git status");
581
- analysis.commands; // ["grep", "git"]
582
- analysis.unresolved; // ["git"]
583
- ```
584
-
585
- Resolution mirrors the runtime resolver: builtins, registered and custom commands, functions defined in the script, and executable files on the VFS PATH all count as resolved. Dynamically determined names (`$cmd status`, `eval "..."`, globs, quotes) can't be analyzed statically and appear in neither list — runtime reporting is the backstop.
586
-
587
- **Fail-fast abort.** With `abortOnUnresolvedCommands`, the script stops at the first miss instead of continuing bash-style, returning exit code 127 with the output accumulated so far:
588
-
589
- ```typescript
590
- const env = new Bash({ abortOnUnresolvedCommands: true });
591
- const result = await env.exec("echo done; git status; echo unreachable");
592
- result.exitCode; // 127
593
- result.stdout; // "done\n"
594
- result.unresolvedCommands; // ["git"]
595
- ```
596
-
597
- A typical harness runs a script entirely in the sandbox or entirely natively:
598
-
599
- ```typescript
600
- const rerunNatively = (script: string): void => {
601
- // Host-provided: run the script outside the sandbox.
602
- };
603
- const bash = new Bash();
604
- const script = "grep -r foo src/ && git status";
605
- const analysis = await bash.analyzeCommands(script);
606
- if (analysis.unresolved.length === 0) {
607
- const result = await bash.exec(script);
608
- if (result.unresolvedCommands?.length) rerunNatively(script);
609
- } else {
610
- rerunNatively(script);
611
- }
612
- ```
613
-
614
- ## AST Transform Plugins
615
-
616
- Parse bash scripts into an AST, transform them, and serialize back to bash. Good for instrumenting scripts (e.g., capturing per-command stdout/stderr) or extracting metadata before execution.
617
-
618
- ```typescript
619
- import { Bash, BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "@jerryan/just-bash";
620
-
621
- // Standalone pipeline — output can be run by any shell
622
- const pipeline = new BashTransformPipeline()
623
- .use(new TeePlugin({ outputDir: "/tmp/logs" }))
624
- .use(new CommandCollectorPlugin());
625
- const result = pipeline.transform("echo hello | grep hello");
626
- result.script; // transformed bash string
627
- result.metadata.commands; // ["echo", "grep", "tee"]
628
-
629
- // Integrated API — exec() auto-applies transforms and returns metadata
630
- const bash = new Bash();
631
- bash.registerTransformPlugin(new CommandCollectorPlugin());
632
- const execResult = await bash.exec("echo hello | grep hello");
633
- execResult.metadata?.commands; // ["echo", "grep"]
634
- ```
635
-
636
- See [src/transform/README.md](src/transform/README.md) for the full API, built-in plugins, and how to write custom plugins.
637
-
638
- ## Integrations
639
-
640
- ### AI SDK Tool
641
-
642
- [`bash-tool`](https://github.com/vercel-labs/bash-tool) wraps just-bash as an [AI SDK](https://ai-sdk.dev/) tool:
643
-
644
- ```bash
645
- npm install bash-tool
646
- ```
647
-
648
- ```typescript
649
- import { createBashTool } from "bash-tool";
650
- import { generateText } from "ai";
651
-
652
- const bashTool = createBashTool({
653
- files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' },
654
- });
655
-
656
- const result = await generateText({
657
- model: "anthropic/claude-sonnet-4",
658
- tools: { bash: bashTool },
659
- prompt: "Count the users in /data/users.json",
660
- });
661
- ```
662
-
663
- See [bash-tool](https://github.com/vercel-labs/bash-tool) for more.
664
-
665
- ### Vercel Sandbox Compatible API
666
-
667
- `Sandbox` is a drop-in replacement for [`@vercel/sandbox`](https://vercel.com/docs/vercel-sandbox) — same API, but runs entirely in-process with the virtual filesystem. Start with just-bash for development and testing, swap in a real sandbox when you need a full VM.
668
-
669
- ```typescript
670
- import { Sandbox } from "@jerryan/just-bash";
671
-
672
- // Create a sandbox instance
673
- const sandbox = await Sandbox.create({ cwd: "/app" });
674
-
675
- // Write files to the virtual filesystem
676
- await sandbox.writeFiles({
677
- "/app/script.sh": 'echo "Hello World"',
678
- "/app/data.json": '{"key": "value"}',
679
- });
680
-
681
- // Run commands and get results
682
- const cmd = await sandbox.runCommand("bash /app/script.sh");
683
- const output = await cmd.stdout(); // "Hello World\n"
684
- const exitCode = (await cmd.wait()).exitCode; // 0
685
-
686
- // Read files back
687
- const content = await sandbox.readFile("/app/data.json");
688
-
689
- // Create directories
690
- await sandbox.mkDir("/app/logs", { recursive: true });
691
-
692
- // Clean up (no-op for Bash, but API-compatible)
693
- await sandbox.stop();
694
- ```
695
-
696
- ## CLI
697
-
698
- ### CLI Binary
699
-
700
- Install globally (`npm install -g @jerryan/just-bash`) for a sandboxed CLI:
701
-
702
- ```bash
703
- # Execute inline script
704
- just-bash -c 'ls -la && cat package.json | head -5'
705
-
706
- # Execute with specific project root
707
- just-bash -c 'grep -r "TODO" src/' --root /path/to/project
708
-
709
- # Pipe script from stdin
710
- echo 'find . -name "*.ts" | wc -l' | just-bash
711
-
712
- # Execute a script file
713
- just-bash ./scripts/deploy.sh
714
-
715
- # Get JSON output for programmatic use
716
- just-bash -c 'echo hello' --json
717
- # Output: {"stdout":"hello\n","stderr":"","exitCode":0}
718
- ```
719
-
720
- The CLI uses OverlayFS — reads come from the real filesystem, but all writes stay in memory and are discarded after execution.
721
-
722
- **Important**: The project root is mounted at `/home/user/project`. Use this path (or relative paths from the default cwd) to access your files inside the sandbox.
723
-
724
- Options:
725
-
726
- - `-c <script>` - Execute script from argument
727
- - `--root <path>` - Root directory (default: current directory)
728
- - `--cwd <path>` - Working directory in sandbox
729
- - `-e, --errexit` - Exit on first error
730
- - `--json` - Output as JSON
731
-
732
- ### Interactive Shell
733
-
734
- ```bash
735
- pnpm shell
736
- ```
737
-
738
- The interactive shell has full internet access by default. Disable with `--no-network`:
739
-
740
- ```bash
741
- pnpm shell --no-network
742
- ```
743
-
744
- ## Execution Protection
745
-
746
- Bash protects against infinite loops and deep recursion with configurable limits:
747
-
748
- ```typescript
749
- const env = new Bash({
750
- // `normal` is the liberal, compatibility-oriented default. Use `hardened`
751
- // for tighter untrusted-workload policy, then override individual resources.
752
- executionLimitProfile: "hardened",
753
- executionLimits: {
754
- maxCallDepth: 100, // Max function recursion depth
755
- maxCommandCount: 20000, // Shared across nested execution
756
- maxSourceBytes: 8 * 1024 * 1024, // Shell source before parsing
757
- maxFileSystemBytes: 256 * 1024 * 1024, // Retained default-FS data
758
- maxOutputSize: 32 * 1024 * 1024, // Aggregate stdout + stderr bytes
759
- maxArchiveBytes: 256 * 1024 * 1024, // Expanded archive bytes
760
- maxDatabaseBytes: 128 * 1024 * 1024, // SQLite image bytes
761
- maxExecutionTimeMs: 30_000, // Whole execution wall-clock deadline
762
- maxExtensionCleanupTimeMs: 25, // Cancellation acknowledgement grace
763
- },
764
- });
765
- ```
766
-
767
- All resources remain bounded by default in both profiles. Explicit values
768
- override the selected profile; non-negative safe integers and the legacy
769
- `Infinity` spelling are accepted. Infinite deadlines omit the corresponding
770
- platform timer rather than overflowing it. Invalid values are rejected when
771
- `Bash` is constructed. Error messages identify the resource that was hit.
772
-
773
- ## Security Model
774
-
775
- The Node.js package requires Node `>=20.18.1`.
776
-
777
- - The shell only has access to the provided filesystem.
778
- - All execution happens without VM isolation. This does introduce additional risk. The code base was designed to be robust against prototype-pollution attacks and other break outs to the host JS engine and filesystem.
779
- - There is no network access by default. When enabled, requests are checked against URL prefix allow-lists and HTTP-method allow-lists.
780
- - Python and JavaScript execution are off by default as they represent additional security surface.
781
- - Execution is protected against infinite loops and deep recursion with configurable limits.
782
- - Host-realm defense-in-depth uses the strongest scoped controls available on
783
- each supported Node runtime. Where `node:module.registerHooks()` is present,
784
- builtin ESM imports can also be denied only for the untrusted async context;
785
- older runtimes retain best-effort scoped protection without failing existing
786
- applications. It never installs a process-global deny-all loader. Query the
787
- resolved capabilities with `DefenseInDepthBox.getInstance().getStatus()`.
788
- Audit mode reports `level: "none"` because it records violations without
789
- enforcing them.
790
- - Scoped defense uses reversible proxies for `Reflect`, `JSON`, and `Math` and
791
- restores their host descriptors on deactivation. This is reported as
792
- `intrinsicProtection: "scoped-best-effort"`: same-realm JavaScript that
793
- cached an intrinsic or a mutation function before activation cannot be fully
794
- revoked (including the direct `delete` operator). The separately named
795
- `processLifetimeIntrinsicHardening: true` option permanently freezes those
796
- objects and locks selected well-known Symbol descriptors; use it only in a
797
- disposable or process-lifetime realm. Use an isolated worker/process when
798
- complete protection and reversible host state are both required.
799
- - Node worker `resourceLimits` do not reliably cap the WebAssembly linear memory used by CPython or sql.js. Queue, deadline, file, database, bridge, and payload limits reduce exposure, but strong memory containment for these opt-in runtimes requires process/container isolation or a WASM build with a lower hard maximum.
800
- - Use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) if you need a full VM with arbitrary binary execution.
801
-
802
- ## Browser Support
803
-
804
- 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).
805
-
806
- ## Default Layout
807
-
808
- When created without options, Bash provides a Unix-like directory structure:
809
-
810
- - `/home/user` - Default working directory (and `$HOME`)
811
- - `/bin` - Contains stubs for all built-in commands
812
- - `/usr/bin` - Additional binary directory
813
- - `/tmp` - Temporary files directory
814
-
815
- Commands can be invoked by path (e.g., `/bin/ls`) or by name.
816
-
817
- ## AI Agent Instructions
818
-
819
- For AI agents, [`bash-tool`](https://github.com/vercel-labs/bash-tool) provides additional guidance in its `AGENTS.md`:
820
-
821
- ```bash
822
- cat node_modules/bash-tool/dist/AGENTS.md
823
- ```
824
-
825
- ## License
826
-
827
- Apache-2.0
1
+ # just-bash
2
+
3
+ A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents.
4
+
5
+ Broad support for standard unix commands and bash syntax with optional curl, Python, JS/TS, and sqlite support.
6
+
7
+ **Note**: This is beta software. Use at your own risk and please provide feedback. See [security model](#security-model).
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ npm install @jerryan/just-bash
13
+ ```
14
+
15
+ ```typescript
16
+ import { Bash } from "@jerryan/just-bash";
17
+
18
+ const bash = new Bash();
19
+ await bash.exec('echo "Hello" > greeting.txt');
20
+ const result = await bash.exec("cat greeting.txt");
21
+ console.log(result.stdout); // "Hello\n"
22
+ console.log(result.exitCode); // 0
23
+ ```
24
+
25
+ Each `exec()` call gets its own isolated shell state — environment variables, functions, and working directory reset between calls. The **filesystem is shared** across calls, so files written in one `exec()` are visible in the next.
26
+
27
+ ## Custom Commands
28
+
29
+ Extend just-bash with your own TypeScript commands using `defineCommand`:
30
+
31
+ ```typescript
32
+ import { Bash, decodeBytesToUtf8, defineCommand } from "@jerryan/just-bash";
33
+
34
+ const hello = defineCommand("hello", async (args, ctx) => {
35
+ const name = args[0] || "world";
36
+ return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 };
37
+ });
38
+
39
+ const upper = defineCommand("upper", async (args, ctx) => {
40
+ // ctx.stdin is a ByteString — decode to text before string ops.
41
+ return {
42
+ stdout: decodeBytesToUtf8(ctx.stdin).toUpperCase(),
43
+ stderr: "",
44
+ exitCode: 0,
45
+ };
46
+ });
47
+
48
+ const bash = new Bash({ customCommands: [hello, upper] });
49
+
50
+ await bash.exec("hello Alice"); // "Hello, Alice!\n"
51
+ await bash.exec("echo 'test' | upper"); // "TEST\n"
52
+ ```
53
+
54
+ Custom command callbacks receive a `ResolvedCommandContext` with `fs`, `cwd`,
55
+ `env`, `stdin`, resolved `limits`, and `exec` (for subcommands), and work with
56
+ pipes, redirections, and all shell features. The legacy `CommandContext` remains
57
+ available for standalone context inputs; use `createCommandContext({ fs })` when
58
+ calling a command directly with a fully resolved context.
59
+
60
+ Host-provided commands preserve the legacy trusted default whether supplied to
61
+ the `Bash` constructor, declared through `defineCommand`, loaded lazily, or
62
+ added later with `bash.registerCommand()`. Set `trusted: false` (or use
63
+ `defineCommand(name, execute, { trusted: false })`) to select the restricted
64
+ extension boundary. Trusted commands run in the embedding process and should
65
+ never execute guest-provided JavaScript.
66
+
67
+ Every invocation is bound by `maxExecutionTimeMs`. On cancellation, just-bash
68
+ revokes the command context immediately; `maxExtensionCleanupTimeMs` only
69
+ bounds how long it waits for the now-authority-free command promise to settle.
70
+ A late continuation cannot use `ctx.fs`, `ctx.env`, `ctx.exec`, or other context
71
+ capabilities. Cleanup work that must run at scope closure can be registered with
72
+ `ctx.executionScope.registerCleanup()`. A cleanup failure is returned as a
73
+ generic exit-126 shell result rather than rejecting `Bash.exec()` or exposing
74
+ host error details. JavaScript cannot forcibly stop arbitrary host code, so
75
+ extensions requiring a hard guarantee against external side effects must run
76
+ in a terminable worker or process. Tests that invoke command objects directly
77
+ can use `createCommandContext({ fs })` to get a fully resolved context without
78
+ duplicating internal defaults.
79
+
80
+ <details>
81
+ <summary><h2>Supported Commands</h2></summary>
82
+
83
+ ### File Operations
84
+
85
+ `cat`, `cp`, `file`, `ln`, `ls`, `mkdir`, `mv`, `readlink`, `rm`, `rmdir`, `split`, `stat`, `touch`, `tree`
86
+
87
+ ### Text Processing
88
+
89
+ `awk`, `base64`, `column`, `comm`, `cut`, `diff`, `expand`, `fold`, `grep` (+ `egrep`, `fgrep`), `head`, `join`, `md5sum`, `nl`, `od`, `paste`, `printf`, `rev`, `rg`, `sed`, `sha1sum`, `sha256sum`, `sort`, `strings`, `tac`, `tail`, `tr`, `unexpand`, `uniq`, `wc`, `xargs`
90
+
91
+ ### Data Processing
92
+
93
+ `jq` (JSON), `sqlite3` (SQLite), `xan` (CSV), `yq` (YAML/XML/TOML/CSV)
94
+
95
+ ### Optional Runtimes
96
+
97
+ `js-exec` (JavaScript/TypeScript via QuickJS; requires `javascript: true`), `python3`/`python` (Python via CPython; requires `python: true`)
98
+
99
+ ### Compression & Archives
100
+
101
+ `gzip` (+ `gunzip`, `zcat`), `tar`
102
+
103
+ ### Navigation & Environment
104
+
105
+ `basename`, `cd`, `dirname`, `du`, `echo`, `env`, `export`, `find`, `hostname`, `printenv`, `pwd`, `tee`
106
+
107
+ ### Shell Utilities
108
+
109
+ `alias`, `bash`, `chmod`, `clear`, `date`, `expr`, `false`, `help`, `history`, `seq`, `sh`, `sleep`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami`
110
+
111
+ ### Network
112
+
113
+ `curl`, `html-to-markdown` (require [network configuration](#network-access))
114
+
115
+ All commands support `--help` for usage information.
116
+
117
+ ### Shell Features
118
+
119
+ - **Pipes**: `cmd1 | cmd2`
120
+ - **Redirections**: `>`, `>>`, `2>`, `2>&1`, `<`
121
+ - **Command chaining**: `&&`, `||`, `;`
122
+ - **Variables**: `$VAR`, `${VAR}`, `${VAR:-default}`
123
+ - **Positional parameters**: `$1`, `$2`, `$@`, `$#`
124
+ - **Glob patterns**: `*`, `?`, `[...]`
125
+ - **If statements**: `if COND; then CMD; elif COND; then CMD; else CMD; fi`
126
+ - **Functions**: `function name { ... }` or `name() { ... }`
127
+ - **Local variables**: `local VAR=value`
128
+ - **Loops**: `for`, `while`, `until`
129
+ - **Symbolic links**: `ln -s target link`
130
+ - **Hard links**: `ln target link`
131
+
132
+ </details>
133
+
134
+ ## Configuration
135
+
136
+ ```typescript
137
+ const env = new Bash({
138
+ files: { "/data/file.txt": "content" }, // Initial files
139
+ env: { MY_VAR: "value" }, // Initial environment
140
+ cwd: "/app", // Starting directory (default: /home/user)
141
+ executionLimits: { maxCallDepth: 50 }, // See "Execution Protection"
142
+ python: true, // Enable python3/python commands
143
+ javascript: true, // Enable js-exec command
144
+ // Or with bootstrap: javascript: { bootstrap: "globalThis.X = 1;" }
145
+ });
146
+
147
+ // Per-exec overrides
148
+ await env.exec("echo $TEMP", { env: { TEMP: "value" }, cwd: "/tmp" });
149
+
150
+ // Pass stdin to the script
151
+ await env.exec("cat", { stdin: "hello from stdin\n" });
152
+
153
+ // Start with a clean environment
154
+ await env.exec("env", { replaceEnv: true, env: { ONLY: "this" } });
155
+
156
+ // Pass arguments without shell escaping (like spawnSync)
157
+ await env.exec("grep", { args: ["-r", "TODO", "src/"] });
158
+
159
+ // Cancel long-running scripts
160
+ const controller = new AbortController();
161
+ setTimeout(() => controller.abort(), 5000);
162
+ await env.exec("while true; do sleep 1; done", { signal: controller.signal });
163
+
164
+ // Preserve leading whitespace (e.g., for heredocs)
165
+ await env.exec("cat <<EOF\n indented\nEOF", { rawScript: true });
166
+ ```
167
+
168
+ ### Timezone
169
+
170
+ `date` defaults to UTC (`%Z=UTC`, `%z=+0000`) regardless of the host clock, so the sandbox does not leak the host timezone. To opt into a specific zone, pass `TZ` as an initial env var:
171
+
172
+ ```typescript
173
+ const bash = new Bash({ env: { TZ: "America/New_York" } });
174
+ await bash.exec("date"); // Mon Jun 1 09:30:00 EDT 2026
175
+ ```
176
+
177
+ `-u` always forces UTC; an unset or invalid `$TZ` falls back to UTC. Setting `TZ` exposes that timezone to scripts running in the sandbox, so only pass a value you are comfortable revealing — forwarding the host's real `$TZ` (e.g. `process.env.TZ`) reintroduces the disclosure that the UTC default exists to prevent.
178
+
179
+ `exec()` options:
180
+
181
+ | Option | Type | Description |
182
+ |---|---|---|
183
+ | `env` | `Record<string, string>` | Environment variables for this execution only |
184
+ | `cwd` | `string` | Working directory for this execution only |
185
+ | `stdin` | `string` | Standard input passed to the script |
186
+ | `args` | `string[]` | Additional argv passed directly to the first command (bypasses shell parsing; does not change `$1`, `$2`, ...) |
187
+ | `replaceEnv` | `boolean` | Start with empty env instead of merging (default: `false`) |
188
+ | `signal` | `AbortSignal` | Cooperative cancellation; stops at next statement boundary |
189
+ | `rawScript` | `boolean` | Skip leading-whitespace normalization (default: `false`) |
190
+
191
+ ## Filesystem Options
192
+
193
+ Four filesystem implementations:
194
+
195
+ **InMemoryFs** (default) - Pure in-memory filesystem, no disk access:
196
+
197
+ ```typescript
198
+ import { Bash } from "@jerryan/just-bash";
199
+
200
+ const env = new Bash({
201
+ files: {
202
+ "/data/config.json": '{"key": "value"}',
203
+ // Lazy: called on first read, cached. Never called if written before read.
204
+ "/data/large.csv": () => "col1,col2\na,b\n",
205
+ "/data/remote.txt": async () => (await fetch("https://example.com")).text(),
206
+ },
207
+ });
208
+ ```
209
+
210
+ **OverlayFs** - Copy-on-write over a real directory. Reads come from disk, writes stay in memory:
211
+
212
+ ```typescript
213
+ import { Bash } from "@jerryan/just-bash";
214
+ import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
215
+
216
+ const overlay = new OverlayFs({
217
+ root: "/path/to/project",
218
+ // Copy-on-write data is bounded independently from real-file reads.
219
+ maxMemoryBytes: 256 * 1024 * 1024,
220
+ });
221
+ const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() });
222
+
223
+ await env.exec("cat package.json"); // reads from disk
224
+ await env.exec('echo "modified" > package.json'); // stays in memory
225
+ ```
226
+
227
+ `maxMemoryBytes` defaults to 1 GiB and covers aggregate files retained in the
228
+ copy-on-write layer, including append chunks. Set it to the deployment's memory
229
+ budget when an `OverlayFs` is reused across executions.
230
+
231
+ **Change sets** — `diff()`, `sync()`, and `reset()` turn the overlay into a
232
+ reviewable sandbox: the agent writes only to memory, the host applies accepted
233
+ changes, and `sync()` drops whatever now matches disk (rejected changes stay
234
+ pending; `reset()` discards everything):
235
+
236
+ ```typescript
237
+ import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
238
+
239
+ const overlay = new OverlayFs({ root: "/path/to/project" });
240
+ // ... run agent commands against the overlay ...
241
+
242
+ const diff = overlay.diff();
243
+ for (const write of diff.writes) {
244
+ // { path, nodeType, content, mode, mtime, metadataOnly? }
245
+ console.log("write", write.path);
246
+ }
247
+ for (const rel of diff.deletions) {
248
+ console.log("delete", rel);
249
+ }
250
+ // Host applies what it accepts to the real directory, then:
251
+ await overlay.sync(); // applied changes drop out of the pending set
252
+ ```
253
+
254
+ **`createAgentSandbox`** bundles the whole setup for the common agent case —
255
+ InMemoryFs root, overlays over the real home and project directories, combined
256
+ change sets with real absolute paths, and `applyChanges()` (apply + drop
257
+ in one call):
258
+
259
+ ```typescript
260
+ import { createAgentSandbox } from "@jerryan/just-bash";
261
+
262
+ const sandbox = createAgentSandbox({
263
+ home: "/real/home",
264
+ project: "/real/project",
265
+ abortOnUnresolvedCommands: true,
266
+ });
267
+ const result = await sandbox.exec("echo hi > notes.txt");
268
+ const changes = sandbox.diff(); // real paths
269
+ await sandbox.applyChanges(changes); // host applies; applied entries drop
270
+ ```
271
+
272
+ See the [agent sandbox integration recipe](../../docs/recipes/agent-sandbox-integration.md)
273
+ and `examples/agent-sandbox.mjs` for the full per-turn loop (static pre-flight,
274
+ sandboxed exec, host-side apply), including the out-of-band modification policy
275
+ you must read before pointing this at a live project.
276
+
277
+ **ReadWriteFs** - Direct read-write access to a real directory. Use this if you want the agent to be able to write to your disk:
278
+
279
+ ```typescript
280
+ import { Bash } from "@jerryan/just-bash";
281
+ import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
282
+
283
+ const rwfs = new ReadWriteFs({ root: "/path/to/sandbox" });
284
+ const env = new Bash({ fs: rwfs });
285
+
286
+ await env.exec('echo "hello" > file.txt'); // writes to real filesystem
287
+ ```
288
+
289
+ Keep `ReadWriteFs` pointed at a workspace directory, not at the installed `just-bash` package or any other trusted runtime code. Guest-writable roots should stay separate from trusted code.
290
+
291
+ `ReadWriteFs` uses normal in-place filesystem operations for private regular
292
+ files. For multiply-linked regular files, it isolates append and metadata
293
+ changes by copying the file and replacing only the sandbox directory entry, so
294
+ a host-created hard link cannot carry those changes beyond the configured root.
295
+ Implicit copies are limited by `maxCopyOnWriteSize` (100 MB by default; set it
296
+ to `0` to disable the limit). Overwrite does not need to copy existing content.
297
+ Explicit `cp` copies can be limited with the opt-in `maxCopySize` option
298
+ (unlimited by default). The portable copy path may materialize sparse-file
299
+ holes, so embeddings that require a disk-allocation bound should configure
300
+ `maxCopySize`.
301
+
302
+ Shared-inode isolation has a few deliberate limitations:
303
+
304
+ - Append, `chmod`, and `utimes` on a multiply-linked regular file require read
305
+ access to the file and write access to its parent directory. They fail with
306
+ `EFBIG` when the file exceeds `maxCopyOnWriteSize`.
307
+ - Copies use `O_NOATIME` when the Node.js runtime exposes it and retry with
308
+ normal read semantics if the kernel returns `EPERM`. Runtimes and platforms
309
+ without `O_NOATIME` may update access-time metadata visible through another
310
+ hard link.
311
+ - Do not mutate a `ReadWriteFs` root concurrently through direct host filesystem
312
+ APIs. Node.js does not expose the descriptor-relative operations needed to
313
+ make pathname validation atomic against an external actor. A concurrent host
314
+ append to a multiply-linked file may be lost when the isolated entry is
315
+ replaced.
316
+ - Mutations in overlapping `ReadWriteFs` roots are serialized within the
317
+ process. Unrelated roots proceed independently. The queue is not cancellable
318
+ or bounded, so a large mutation can delay later operations in overlapping
319
+ roots even if the requesting script is subsequently aborted.
320
+ - Content writes and appends to FIFOs, sockets, devices, and other special files
321
+ are rejected. This avoids indefinitely occupying an overlapping-root mutation
322
+ slot on a blocking special-file open. Metadata operations remain supported
323
+ for single-link special files; multiply-linked special files are rejected
324
+ because they cannot be isolated without changing their file type.
325
+ - Private-file and single-link special-file metadata operations use pathname
326
+ APIs to preserve normal host permission semantics. They are not atomic
327
+ against a trusted host actor concurrently replacing that pathname with a
328
+ symlink. With `allowSymlinks: false`, symlinks present during normal path
329
+ validation are still rejected.
330
+ - Copying a symlink preserves whether its guest target is absolute or relative.
331
+ Only symlinks whose resolved targets remain inside the root are copied.
332
+ - Regular-file copies replace the destination entry to prevent writes through
333
+ hard links. Existing destinations must still be writable, and their parent
334
+ directory must be writable so the isolated entry can be committed. Thus a
335
+ writable destination in a non-writable directory cannot be copied over.
336
+ Copying over a FIFO, socket, device, or other special entry is rejected.
337
+
338
+ **MountableFs** - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace:
339
+
340
+ ```typescript
341
+ import { Bash, MountableFs, InMemoryFs } from "@jerryan/just-bash";
342
+ import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
343
+ import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
344
+
345
+ const fs = new MountableFs({ base: new InMemoryFs() });
346
+
347
+ // Mount read-only knowledge base
348
+ fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true }));
349
+
350
+ // Mount read-write workspace
351
+ fs.mount("/home/agent", new ReadWriteFs({ root: "/path/to/workspace" }));
352
+
353
+ const bash = new Bash({ fs, cwd: "/home/agent" });
354
+
355
+ await bash.exec("ls /mnt/knowledge"); // reads from knowledge base
356
+ await bash.exec("cp /mnt/knowledge/doc.txt ./"); // cross-mount copy
357
+ await bash.exec('echo "notes" > notes.txt'); // writes to workspace
358
+ ```
359
+
360
+ You can also configure mounts in the constructor:
361
+
362
+ ```typescript
363
+ import { MountableFs, InMemoryFs } from "@jerryan/just-bash";
364
+ import { OverlayFs } from "@jerryan/just-bash/fs/overlay-fs";
365
+ import { ReadWriteFs } from "@jerryan/just-bash/fs/read-write-fs";
366
+
367
+ const fs = new MountableFs({
368
+ base: new InMemoryFs(),
369
+ mounts: [
370
+ { mountPoint: "/data", filesystem: new OverlayFs({ root: "/shared/data" }) },
371
+ { mountPoint: "/workspace", filesystem: new ReadWriteFs({ root: "/tmp/work" }) },
372
+ ],
373
+ });
374
+ ```
375
+
376
+ ## Optional Capabilities
377
+
378
+ ### Network Access
379
+
380
+ Network access is disabled by default. Enable it with the `network` option:
381
+
382
+ ```typescript
383
+ // Allow specific URLs with GET/HEAD only (safest)
384
+ const env = new Bash({
385
+ network: {
386
+ allowedUrlPrefixes: [
387
+ "https://api.github.com/repos/myorg/",
388
+ "https://api.example.com",
389
+ ],
390
+ },
391
+ });
392
+
393
+ // Allow specific URLs with additional methods
394
+ const env = new Bash({
395
+ network: {
396
+ allowedUrlPrefixes: ["https://api.example.com"],
397
+ allowedMethods: ["GET", "HEAD", "POST"], // Default: ["GET", "HEAD"]
398
+ },
399
+ });
400
+
401
+ // Inject credentials via header transforms (secrets never enter the sandbox)
402
+ const env = new Bash({
403
+ network: {
404
+ allowedUrlPrefixes: [
405
+ "https://public-api.com", // plain string — no transforms
406
+ {
407
+ url: "https://ai-gateway.vercel.sh",
408
+ transform: [{ headers: { Authorization: "Bearer secret" } }],
409
+ },
410
+ ],
411
+ },
412
+ });
413
+
414
+ // Allow all URLs and methods (use with caution)
415
+ const env = new Bash({
416
+ network: { dangerouslyAllowFullInternetAccess: true },
417
+ });
418
+ ```
419
+
420
+ **Note:** The `curl` command only exists when network is configured. Without network configuration, `curl` returns "command not found".
421
+
422
+ #### Allow-List Security
423
+
424
+ The allow-list enforces:
425
+
426
+ - **Origin matching**: URLs must match the exact origin (scheme + host + port)
427
+ - **Path prefix**: Only paths starting with the specified prefix are allowed
428
+ - **HTTP method restrictions**: Only GET and HEAD by default (configure `allowedMethods` for more)
429
+ - **Redirect protection**: Redirects to non-allowed URLs are blocked
430
+ - **Header transforms**: Firewall headers are injected at the fetch boundary and override any user-supplied headers with the same name, preventing credential substitution from inside the sandbox. Headers are re-evaluated on each redirect so credentials are never leaked to non-transform hosts
431
+
432
+ #### Using curl
433
+
434
+ ```bash
435
+ # Fetch and process data
436
+ curl -s https://api.example.com/data | grep pattern
437
+
438
+ # Download and convert HTML to Markdown
439
+ curl -s https://example.com | html-to-markdown
440
+
441
+ # POST JSON data
442
+ curl -X POST -H "Content-Type: application/json" \
443
+ -d '{"key":"value"}' https://api.example.com/endpoint
444
+ ```
445
+
446
+ ### JavaScript Support
447
+
448
+ JavaScript and TypeScript execution via QuickJS is opt-in due to additional security surface. Enable with `javascript: true`:
449
+
450
+ ```typescript
451
+ const env = new Bash({
452
+ javascript: true,
453
+ });
454
+
455
+ // Execute JavaScript code
456
+ await env.exec('js-exec -c "console.log(1 + 2)"');
457
+
458
+ // Run script files (.js, .mjs, .ts, .mts)
459
+ await env.exec('js-exec script.js');
460
+
461
+ // ES module mode with imports
462
+ await env.exec('js-exec -m -c "import fs from \'fs\'; console.log(fs.readFileSync(\'/data/file.txt\', \'utf8\'))"');
463
+ ```
464
+
465
+ #### Bootstrap Code
466
+
467
+ Run setup code before every `js-exec` invocation with the `bootstrap` option:
468
+
469
+ ```typescript
470
+ const env = new Bash({
471
+ javascript: {
472
+ bootstrap: `
473
+ globalThis.API_BASE = "https://api.example.com";
474
+ globalThis.formatDate = (d) => new Date(d).toISOString();
475
+ `,
476
+ },
477
+ });
478
+
479
+ await env.exec('js-exec -c "console.log(API_BASE)"');
480
+ // Output: https://api.example.com
481
+ ```
482
+
483
+ #### Node.js Compatibility
484
+
485
+ `js-exec` supports `require()` and `import` with these Node.js modules:
486
+
487
+ - **fs**: `readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `rmSync`, `fs.promises.*`
488
+ - **path**: `join`, `resolve`, `dirname`, `basename`, `extname`, `relative`, `normalize`
489
+ - **child_process**: `execSync`, `spawnSync`
490
+ - **process**: `argv`, `cwd()`, `exit()`, `env`, `platform`, `version`
491
+ - **Other modules**: `os`, `url`, `assert`, `util`, `events`, `buffer`, `stream`, `string_decoder`, `querystring`
492
+ - **Globals**: `console`, `fetch`, `Buffer`, `URL`, `URLSearchParams`
493
+
494
+ `fs.readFileSync()` returns a `Buffer` by default (matching Node.js). Pass an encoding like `'utf8'` to get a string.
495
+
496
+ **Note:** The `js-exec` command only exists when `javascript` is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (30 seconds in the default `normal` profile and 10 seconds in the opt-in `hardened` profile). Enabling network access does not extend the configured deadline.
497
+
498
+ #### Tool Invocation Hook
499
+
500
+ `js-exec` scripts can call host-defined tools through a global `tools` proxy
501
+ when `javascript.invokeTool` is provided:
502
+
503
+ ```typescript
504
+ const bash = new Bash({
505
+ javascript: {
506
+ // path: "math.add" (dot-separated)
507
+ // argsJson: '{"a":1,"b":2}' (or "" for no args)
508
+ // return: JSON-stringified result, or "" for undefined
509
+ // throw: propagates as a sandbox exception
510
+ invokeTool: async (path, argsJson) => {
511
+ const args = argsJson ? JSON.parse(argsJson) : undefined;
512
+ if (path === "math.add") {
513
+ return JSON.stringify({ sum: args.a + args.b });
514
+ }
515
+ throw new Error(`Unknown tool: ${path}`);
516
+ },
517
+ },
518
+ });
519
+
520
+ await bash.exec(`js-exec -c 'console.log((await tools.math.add({a:3,b:4})).sum)'`);
521
+ ```
522
+
523
+ The hook is generic — wire any tool framework through it (raw maps, MCP,
524
+ Anthropic tool-use, etc.). For full GraphQL / OpenAPI / MCP discovery via
525
+ `@executor-js/sdk`, plus auto-generated bash namespace commands, use the
526
+ companion package
527
+ [**`@just-bash/executor`**](../just-bash-executor/README.md).
528
+
529
+ ### Python Support
530
+
531
+ Python (CPython compiled to WASM) is opt-in due to additional security surface. Enable with `python: true`:
532
+
533
+ ```typescript
534
+ const env = new Bash({
535
+ python: true,
536
+ });
537
+
538
+ // Execute Python code
539
+ await env.exec('python3 -c "print(1 + 2)"');
540
+
541
+ // Run Python scripts
542
+ await env.exec('python3 script.py');
543
+ ```
544
+
545
+ **Note:** The `python3` and `python` commands only exist when `python: true` is configured. Python is not available in browser environments.
546
+
547
+ ### SQLite Support
548
+
549
+ `sqlite3` uses sql.js (SQLite compiled to WASM), sandboxed from the real filesystem:
550
+
551
+ ```typescript
552
+ const env = new Bash();
553
+
554
+ // Query in-memory database
555
+ await env.exec('sqlite3 :memory: "SELECT 1 + 1"');
556
+
557
+ // Query file-based database
558
+ await env.exec('sqlite3 data.db "SELECT * FROM users"');
559
+ ```
560
+
561
+ **Note:** SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (30 seconds in the default `normal` profile and 5 seconds in the opt-in `hardened` profile) to prevent runaway queries from blocking execution.
562
+
563
+ ## Detecting Unsupported Commands
564
+
565
+ Agent harnesses need to know when a script uses commands the sandbox doesn't implement — anything the sandbox can't run should be *visible* instead of silently wrong. Three layers, all opt-in except the first:
566
+
567
+ **Runtime reporting (always on).** Every `exec()` result lists command names that failed resolution, captured from anywhere in the script — subshells, pipelines, command substitutions, nested `bash -c`:
568
+
569
+ ```typescript
570
+ const bash = new Bash();
571
+ const result = await bash.exec("grep -r foo src/ && git status");
572
+ result.unresolvedCommands; // ["git"]
573
+ result.exitCode; // 0 — execution continued bash-style (the miss itself exited 127)
574
+ ```
575
+
576
+ **Static pre-flight.** `analyzeCommands()` parses a script and reports which literal command names would fail resolution, without executing anything or modifying state. Useful for skipping doomed scripts before they cause side effects:
577
+
578
+ ```typescript
579
+ const bash = new Bash();
580
+ const analysis = await bash.analyzeCommands("grep -r foo src/ && git status");
581
+ analysis.commands; // ["grep", "git"]
582
+ analysis.unresolved; // ["git"]
583
+ ```
584
+
585
+ Resolution mirrors the runtime resolver: builtins, registered and custom commands, functions defined in the script, and executable files on the VFS PATH all count as resolved. Dynamically determined names (`$cmd status`, `eval "..."`, globs, quotes) can't be analyzed statically and appear in neither list — runtime reporting is the backstop.
586
+
587
+ **Fail-fast abort.** With `abortOnUnresolvedCommands`, the script stops at the first miss instead of continuing bash-style, returning exit code 127 with the output accumulated so far:
588
+
589
+ ```typescript
590
+ const env = new Bash({ abortOnUnresolvedCommands: true });
591
+ const result = await env.exec("echo done; git status; echo unreachable");
592
+ result.exitCode; // 127
593
+ result.stdout; // "done\n"
594
+ result.unresolvedCommands; // ["git"]
595
+ ```
596
+
597
+ A typical harness runs a script entirely in the sandbox or entirely natively:
598
+
599
+ ```typescript
600
+ const rerunNatively = (script: string): void => {
601
+ // Host-provided: run the script outside the sandbox.
602
+ };
603
+ const bash = new Bash();
604
+ const script = "grep -r foo src/ && git status";
605
+ const analysis = await bash.analyzeCommands(script);
606
+ if (analysis.unresolved.length === 0) {
607
+ const result = await bash.exec(script);
608
+ if (result.unresolvedCommands?.length) rerunNatively(script);
609
+ } else {
610
+ rerunNatively(script);
611
+ }
612
+ ```
613
+
614
+ ## AST Transform Plugins
615
+
616
+ Parse bash scripts into an AST, transform them, and serialize back to bash. Good for instrumenting scripts (e.g., capturing per-command stdout/stderr) or extracting metadata before execution.
617
+
618
+ ```typescript
619
+ import { Bash, BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "@jerryan/just-bash";
620
+
621
+ // Standalone pipeline — output can be run by any shell
622
+ const pipeline = new BashTransformPipeline()
623
+ .use(new TeePlugin({ outputDir: "/tmp/logs" }))
624
+ .use(new CommandCollectorPlugin());
625
+ const result = pipeline.transform("echo hello | grep hello");
626
+ result.script; // transformed bash string
627
+ result.metadata.commands; // ["echo", "grep", "tee"]
628
+
629
+ // Integrated API — exec() auto-applies transforms and returns metadata
630
+ const bash = new Bash();
631
+ bash.registerTransformPlugin(new CommandCollectorPlugin());
632
+ const execResult = await bash.exec("echo hello | grep hello");
633
+ execResult.metadata?.commands; // ["echo", "grep"]
634
+ ```
635
+
636
+ See [src/transform/README.md](src/transform/README.md) for the full API, built-in plugins, and how to write custom plugins.
637
+
638
+ ## Integrations
639
+
640
+ ### AI SDK Tool
641
+
642
+ [`bash-tool`](https://github.com/vercel-labs/bash-tool) wraps just-bash as an [AI SDK](https://ai-sdk.dev/) tool:
643
+
644
+ ```bash
645
+ npm install bash-tool
646
+ ```
647
+
648
+ ```typescript
649
+ import { createBashTool } from "bash-tool";
650
+ import { generateText } from "ai";
651
+
652
+ const bashTool = createBashTool({
653
+ files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' },
654
+ });
655
+
656
+ const result = await generateText({
657
+ model: "anthropic/claude-sonnet-4",
658
+ tools: { bash: bashTool },
659
+ prompt: "Count the users in /data/users.json",
660
+ });
661
+ ```
662
+
663
+ See [bash-tool](https://github.com/vercel-labs/bash-tool) for more.
664
+
665
+ ### Vercel Sandbox Compatible API
666
+
667
+ `Sandbox` is a drop-in replacement for [`@vercel/sandbox`](https://vercel.com/docs/vercel-sandbox) — same API, but runs entirely in-process with the virtual filesystem. Start with just-bash for development and testing, swap in a real sandbox when you need a full VM.
668
+
669
+ ```typescript
670
+ import { Sandbox } from "@jerryan/just-bash";
671
+
672
+ // Create a sandbox instance
673
+ const sandbox = await Sandbox.create({ cwd: "/app" });
674
+
675
+ // Write files to the virtual filesystem
676
+ await sandbox.writeFiles({
677
+ "/app/script.sh": 'echo "Hello World"',
678
+ "/app/data.json": '{"key": "value"}',
679
+ });
680
+
681
+ // Run commands and get results
682
+ const cmd = await sandbox.runCommand("bash /app/script.sh");
683
+ const output = await cmd.stdout(); // "Hello World\n"
684
+ const exitCode = (await cmd.wait()).exitCode; // 0
685
+
686
+ // Read files back
687
+ const content = await sandbox.readFile("/app/data.json");
688
+
689
+ // Create directories
690
+ await sandbox.mkDir("/app/logs", { recursive: true });
691
+
692
+ // Clean up (no-op for Bash, but API-compatible)
693
+ await sandbox.stop();
694
+ ```
695
+
696
+ ## CLI
697
+
698
+ ### CLI Binary
699
+
700
+ Install globally (`npm install -g @jerryan/just-bash`) for a sandboxed CLI:
701
+
702
+ ```bash
703
+ # Execute inline script
704
+ just-bash -c 'ls -la && cat package.json | head -5'
705
+
706
+ # Execute with specific project root
707
+ just-bash -c 'grep -r "TODO" src/' --root /path/to/project
708
+
709
+ # Pipe script from stdin
710
+ echo 'find . -name "*.ts" | wc -l' | just-bash
711
+
712
+ # Execute a script file
713
+ just-bash ./scripts/deploy.sh
714
+
715
+ # Get JSON output for programmatic use
716
+ just-bash -c 'echo hello' --json
717
+ # Output: {"stdout":"hello\n","stderr":"","exitCode":0}
718
+ ```
719
+
720
+ The CLI uses OverlayFS — reads come from the real filesystem, but all writes stay in memory and are discarded after execution.
721
+
722
+ **Important**: The project root is mounted at `/home/user/project`. Use this path (or relative paths from the default cwd) to access your files inside the sandbox.
723
+
724
+ Options:
725
+
726
+ - `-c <script>` - Execute script from argument
727
+ - `--root <path>` - Root directory (default: current directory)
728
+ - `--cwd <path>` - Working directory in sandbox
729
+ - `-e, --errexit` - Exit on first error
730
+ - `--json` - Output as JSON
731
+
732
+ ### Interactive Shell
733
+
734
+ ```bash
735
+ pnpm shell
736
+ ```
737
+
738
+ The interactive shell has full internet access by default. Disable with `--no-network`:
739
+
740
+ ```bash
741
+ pnpm shell --no-network
742
+ ```
743
+
744
+ ## Execution Protection
745
+
746
+ Bash protects against infinite loops and deep recursion with configurable limits:
747
+
748
+ ```typescript
749
+ const env = new Bash({
750
+ // `normal` is the liberal, compatibility-oriented default. Use `hardened`
751
+ // for tighter untrusted-workload policy, then override individual resources.
752
+ executionLimitProfile: "hardened",
753
+ executionLimits: {
754
+ maxCallDepth: 100, // Max function recursion depth
755
+ maxCommandCount: 20000, // Shared across nested execution
756
+ maxSourceBytes: 8 * 1024 * 1024, // Shell source before parsing
757
+ maxFileSystemBytes: 256 * 1024 * 1024, // Retained default-FS data
758
+ maxOutputSize: 32 * 1024 * 1024, // Aggregate stdout + stderr bytes
759
+ maxArchiveBytes: 256 * 1024 * 1024, // Expanded archive bytes
760
+ maxDatabaseBytes: 128 * 1024 * 1024, // SQLite image bytes
761
+ maxExecutionTimeMs: 30_000, // Whole execution wall-clock deadline
762
+ maxExtensionCleanupTimeMs: 25, // Cancellation acknowledgement grace
763
+ },
764
+ });
765
+ ```
766
+
767
+ All resources remain bounded by default in both profiles. Explicit values
768
+ override the selected profile; non-negative safe integers and the legacy
769
+ `Infinity` spelling are accepted. Infinite deadlines omit the corresponding
770
+ platform timer rather than overflowing it. Invalid values are rejected when
771
+ `Bash` is constructed. Error messages identify the resource that was hit.
772
+
773
+ ## Security Model
774
+
775
+ The Node.js package requires Node `>=20.18.1`.
776
+
777
+ - The shell only has access to the provided filesystem.
778
+ - All execution happens without VM isolation. This does introduce additional risk. The code base was designed to be robust against prototype-pollution attacks and other break outs to the host JS engine and filesystem.
779
+ - There is no network access by default. When enabled, requests are checked against URL prefix allow-lists and HTTP-method allow-lists.
780
+ - Python and JavaScript execution are off by default as they represent additional security surface.
781
+ - Execution is protected against infinite loops and deep recursion with configurable limits.
782
+ - Host-realm defense-in-depth uses the strongest scoped controls available on
783
+ each supported Node runtime. Where `node:module.registerHooks()` is present,
784
+ builtin ESM imports can also be denied only for the untrusted async context;
785
+ older runtimes retain best-effort scoped protection without failing existing
786
+ applications. It never installs a process-global deny-all loader. Query the
787
+ resolved capabilities with `DefenseInDepthBox.getInstance().getStatus()`.
788
+ Audit mode reports `level: "none"` because it records violations without
789
+ enforcing them.
790
+ - Scoped defense uses reversible proxies for `Reflect`, `JSON`, and `Math` and
791
+ restores their host descriptors on deactivation. This is reported as
792
+ `intrinsicProtection: "scoped-best-effort"`: same-realm JavaScript that
793
+ cached an intrinsic or a mutation function before activation cannot be fully
794
+ revoked (including the direct `delete` operator). The separately named
795
+ `processLifetimeIntrinsicHardening: true` option permanently freezes those
796
+ objects and locks selected well-known Symbol descriptors; use it only in a
797
+ disposable or process-lifetime realm. Use an isolated worker/process when
798
+ complete protection and reversible host state are both required.
799
+ - Node worker `resourceLimits` do not reliably cap the WebAssembly linear memory used by CPython or sql.js. Queue, deadline, file, database, bridge, and payload limits reduce exposure, but strong memory containment for these opt-in runtimes requires process/container isolation or a WASM build with a lower hard maximum.
800
+ - Use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) if you need a full VM with arbitrary binary execution.
801
+
802
+ ## Browser Support
803
+
804
+ 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).
805
+
806
+ ## Default Layout
807
+
808
+ When created without options, Bash provides a Unix-like directory structure:
809
+
810
+ - `/home/user` - Default working directory (and `$HOME`)
811
+ - `/bin` - Contains stubs for all built-in commands
812
+ - `/usr/bin` - Additional binary directory
813
+ - `/tmp` - Temporary files directory
814
+
815
+ Commands can be invoked by path (e.g., `/bin/ls`) or by name.
816
+
817
+ ## AI Agent Instructions
818
+
819
+ For AI agents, [`bash-tool`](https://github.com/vercel-labs/bash-tool) provides additional guidance in its `AGENTS.md`:
820
+
821
+ ```bash
822
+ cat node_modules/bash-tool/dist/AGENTS.md
823
+ ```
824
+
825
+ ## License
826
+
827
+ Apache-2.0