Run untrusted bash inside your JVM. No real bash. No host access. No Docker.
Bashkit4j is a Java SDK for executing shell scripts in a sandboxed virtual
computer: a POSIX-style bash with 160+ commands (grep, sed, awk, jq,
tar, find, …) re-implemented in Rust, an in-memory virtual filesystem, and
a network-denied-by-default execution model — all behind a small, typed Java
API. The script gets a computer that doesn't exist. Your machine stays yours.
Give it a real job — analyzing your project with read-only eyes, writing conclusions into its own memory:
try (Bash bash = Bash.builder()
.allowMountsUnder("C:/dev/my-app") // opt in: all it may ever see
.mount("/project", "C:/dev/my-app") // mount your project — read-only
.file("/notes.txt", "todo-review\nscratch\n") // its own in-memory scratch space
.build()) {
bash.exec("grep -rn TODO /project/src | head -5"); // real files, zero risk
bash.exec("sort /notes.txt | tr a-z A-Z > /out.txt"); // writes stay in the sandbox
System.out.println(bash.readFile("/out.txt")); // ...and you read them back
} // close() frees it. Your disk was never writable. No process was ever spawned.Maven
<dependency>
<groupId>io.github.terseprompts</groupId>
<artifactId>bashkit4j</artifactId>
<version>0.3.0</version>
</dependency>Gradle
implementation 'io.github.terseprompts:bashkit4j:0.3.0'That's the whole install story: Java 17+, one artifact on Maven Central. Native libraries for Windows, Linux and macOS (x86-64 + ARM64) are bundled inside the jar and auto-detected at load time — no Docker daemon, no bash binary on the host, nothing else to provision. Browse the Javadoc.
Every app eventually shells out — build steps, user automation, AI agents with a "terminal". The standard toolkit makes that a security decision:
ProcessBuilder / Runtime.exec() / Docker |
Bashkit4j | |
|---|---|---|
| Real bash on the host | ✅ runs — full attack surface | ❌ never — bash is re-implemented in the library |
| Host filesystem visible to the script | ✅ yes | ❌ invisible — scripts see only the in-memory VFS |
| OS processes spawned per command | ✅ one per call | ❌ zero — everything runs in-process |
| Network access | ✅ open by default | ❌ denied by default |
| Startup cost | seconds (container) | milliseconds (in-process) |
| Multi-tenant isolation | roll your own | built-in — one instance = one tenant |
Before: every bash command is a new OS process; one rogue script is a node down. After: zero OS processes, zero blast radius.
Running untrusted code with trusted permissions is a bet you lose eventually. Bashkit4j takes the permissions away instead.
Everything goes through one entry point: Bash.builder(). Configure a sandbox,
run scripts, read results, throw it away.
try (Bash bash = Bash.builder()
.cwd("/workspace") // virtual working directory
.username("agent").hostname("sandbox") // virtual identity for whoami/id/$USER
.env("CI", "true") // environment variables
.file("/workspace/app.conf", "debug=1")// pre-seeded files (text)
.maxCommands(1000) // resource limits
.timeoutMs(30_000) // wall-clock cap per exec call
.build()) {
// every instance is fully isolated — nothing is shared between instances
}State (variables, files, cwd) persists across exec calls within one
instance, and provably cannot leak across instances.
ExecResult ok = bash.exec("echo hi && grep -r TODO .");
// ExecResult = stdout, stderr, exitCode, stdoutTruncated, stderrTruncated,
// finalEnvJson (exported vars, when capture_final_env is on)
ExecResult fail = bash.exec("exit 7");
fail.exitCode(); // 7 — non-zero exit is a normal result, not an exception
bash.execOrThrow("false"); // throws BashException (message = stderr) on non-zeroRunaway scripts don't take the instance down with them. timeoutMs caps each
exec call on the wall clock; cancel() stops a running script from any
thread — a cancelled agent turn doesn't have to wait for its command to exit:
// .timeoutMs(5000) on the builder: a stuck script throws instead of hanging forever
try {
bash.exec("while true; do :; done"); // throws BashException: execution timeout
} catch (BashException e) { /* instance stays usable */ }
Thread canceller = new Thread(() -> {
try { Thread.sleep(200); } catch (InterruptedException ignored) { }
bash.cancel(); // abort lands at the next command boundary
});
canceller.start();
bash.exec("while true; do :; done"); // throws BashException (status 7 = cancelled)
canceller.join();
bash.clearCancel(); // flag is sticky — reset before the next execbash.writeFile("/data/config.json", "{\"debug\":true}"); // strings…
bash.writeFile("/data/blob.bin", new byte[]{0, 1, (byte)0xff}); // …or exact bytes
String cfg = bash.readFile("/data/config.json");
byte[] raw = bash.readFileBytes("/data/blob.bin");
bash.mkdir("/tmp/work", true);
bash.remove("/tmp/work", true);Scripts and your Java code share the same virtual filesystem — hand data in, get results out, no temp files on the host.
The default sandbox sees no host paths. When a script genuinely needs your files, mount them deliberately:
try (Bash bash = Bash.builder()
.allowMountsUnder("C:/dev/projects") // what MAY be mounted
.mount("/workspace", "C:/dev/projects/my-app") // read-only mount
.build()) {
bash.exec("ls /workspace && cat /workspace/README.md");
bash.mount("/data", "C:/dev/data", true); // live mount, writable
bash.exec("grep -r TODO /data | head");
bash.unmount("/data"); // shell state preserved
}- The allowlist is enforced inside the native library: mount roots are
canonicalized, so
..segments and symlink tricks can't escape an allowlisted prefix, and every mount (builder-time or live) must resolve under one. - No
allowMountsUnder(...)→ no mounts, ever. The sandbox stays airtight by construction.
BashkitRuntime.library(); // load + cache the native lib (JNA)
BashkitRuntime.abiVersion(); // guards the C ABI contract (1)
BashkitRuntime.version(); // native engine version
BashkitRuntime.capabilitiesJson(); // {"abi":1,"features":["git","jq","vfs","realfs-mounts"]}
BashkitRuntime.supports("realfs-mounts"); // feature-detect without hard failure
BashkitRuntime.supports("cancellation"); // gates bash.cancel()/clearCancel()Bashkit4j ships 51 tests (mvn test) that run against the real native
library on Windows, Linux and macOS — including deliberate escape attempts:
| Probe | Result |
|---|---|
ls / |
Only the virtual dev home tmp |
test -e /etc/passwd |
no-passwd — host files unreachable |
test -f C:/Windows/win.ini |
no — host (Windows) paths unreachable |
ls / ../.. |
Same listing — .. cannot escape |
curl https://example.com |
network access not configured |
id |
uid=1000(sandbox) — a virtual identity, not your OS user |
| Two instances, different env | Each sees only its own variables |
Mount + cat /data/../secret.txt |
Blocked — traversal cannot leave the mount root |
| Write to a read-only mount | Fails; host file provably never appears |
.timeoutMs(500) + sleep 30000 |
BashException ("execution timeout"); instance still usable |
cancel() from another thread mid-script |
Script aborts at the next command boundary, status 7 |
- AI/LLM engineers — give a coding agent a real terminal (
ls,cat,grep,tar,jq, …) without letting it touch the machine running it. Deterministicclose(), per-tenant isolation, resource limits. - SaaS / multi-tenant platforms — run user-supplied scripts or "serverless
functions" fully isolated, one
Bashinstance per tenant. - Security-conscious teams — anything that used
ProcessBuilderorRuntime.exec()gets a sandbox instead of a raw process, for free. - CI / automation tooling — replace shell-in-Docker setups with an in-process shell that starts in milliseconds and needs no daemon.
Just Java 17+. The native engine is bundled in the jar and auto-detected:
| Platform | Library |
|---|---|
| Windows x86-64 | bashkit.dll |
| Linux x86-64 | libbashkit.so |
| Linux ARM64 | libbashkit.so |
| macOS x86-64 | libbashkit.dylib |
| macOS ARM64 | libbashkit.dylib |
Resolution order: system property -Dbashkit.native.path → env var
BASHKIT_NATIVE_PATH → bundled auto-detect → platform java.library.path.
- Mounts are opt-in and allowlisted (see above) — implemented in the
Bashkit C ABI itself and merged upstream
(everruns/bashkit#2371,
together with the cancellation API). Without
allowMountsUnder(...)no host path is ever visible. curl/wgetexist as commands but are hard-unavailable in this build — network stays denied; there is noallowNetworkescape hatch yet.- Minor shell semantics worth knowing:
wc -lcounts newlines;${#UNDEF}is0; resource-limit overruns (includingtimeoutMsandcancel()) surface asBashException, not a non-zeroExecResult. timeoutMsandcancel()take effect at command boundaries. A timeout also interrupts a pending command (a stucksleepdies with its deadline); a cancel does not — it waits for the running command to finish, so paircancel()withtimeoutMsto bound that wait. After a cancel, callclearCancel()(or discard the instance) before the nextexec.
- M1
BashToolLLM/agent layer — tool metadata, input/output schema,systemPrompt(), typed errors. - M2 Packaging — native libs bundled for 5 platforms, auto-detected; 3-OS CI matrix.
- M3a Host-directory mounts over the C ABI — opt-in, natively allowlisted (0.2.0); merged upstream.
- M3b Execution controls over the C ABI — wall-clock
timeoutMsandcancel()/clearCancel()(newBASHKIT_CANCELLEDstatus), checked at command boundaries (0.3.0); merged upstream with mounts. - M3c Closer C-ABI gaps — JNI for streaming output, custom builtins, snapshots.
- M4 Published to Maven Central — io.github.terseprompts:bashkit4j.
You normally don't need this — the artifact is on Maven Central with all native libraries bundled.
git clone https://github.com/tersePrompts/bashkit4j.git
cd bashkit4j
mvn test # 51 tests against the real native library
mvn -q compile exec:java # runnable demo, including a live host-mountMIT. Bashkit4j is an independent Java binding of everruns/bashkit (MIT); the bundled native libraries are built from our fork of that project and distributed under the same license. See NOTICE for attribution and terms.