just-bash
A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents.
just-bash は、bash インタプリタと仮想ファイルシステムを TypeScript で実装した npm パッケージ。実プロセスを起動せず、パーサからコマンド実装まですべて JavaScript のコードとして動く。AI エージェントに bash を渡したいが、ホストのファイルシステムやネットワークには触れさせたくない、という用途を想定している。
設計上の中心は「スクリプトは完全に信用しない」という前提。ファイルアクセスは仮想 FS に閉じ、ネットワークは既定で無効、Python と JavaScript の実行は明示的に有効化しないと存在しない。防御は 1 枚ではなく、アーキテクチャ・FS・ネットワーク・実行リミット・グローバル遮蔽の複数層で構成される。
npm install just-bash
import { Bash } from "just-bash";
const bash = new Bash();
await bash.exec('echo "Hello" > greeting.txt');
const result = await bash.exec("cat greeting.txt");
console.log(result.stdout); // "Hello\n"
console.log(result.exitCode); // 0
exec() の呼び出しごとにシェル状態(環境変数・関数・カレントディレクトリ)はリセットされる。一方でファイルシステムは呼び出し間で共有されるため、前の exec() で書いたファイルは次の exec() から見える。
| オプション | 型 | 説明 |
|---|---|---|
env | Record<string, string> | この実行だけに適用する環境変数 |
cwd | string | この実行だけに適用する作業ディレクトリ |
stdin | string | スクリプトへ渡す標準入力 |
args | string[] | 先頭コマンドへ直接渡す argv。シェルのパースを経由しない($1, $2 は変わらない) |
replaceEnv | boolean | 既存 env とマージせず空から始める(既定 false) |
signal | AbortSignal | 協調的キャンセル。次のステートメント境界で停止する |
rawScript | boolean | 先頭空白の正規化をスキップする(既定 false) |
defineCommand で独自コマンドを登録すると、パイプ・リダイレクト・その他のシェル機能とそのまま組み合わせて使える。コールバックは fs、cwd、env、stdin、解決済みの limits、サブコマンド用の exec を持つコンテキストを受け取る。
import { Bash, decodeBytesToUtf8, defineCommand } from "just-bash";
const upper = defineCommand("upper", async (args, ctx) => {
// ctx.stdin は ByteString。文字列操作の前にデコードする。
return {
stdout: decodeBytesToUtf8(ctx.stdin).toUpperCase(),
stderr: "",
exitCode: 0,
};
});
const bash = new Bash({ customCommands: [upper] });
await bash.exec("echo 'test' | upper"); // "TEST\n"
ホスト提供コマンドは既定で trusted 扱いになる。制限付きの拡張境界を選ぶ場合は trusted: false を指定する。trusted なコマンドは埋め込み側のプロセスで動くため、ゲスト由来の JavaScript を実行してはいけない。
maxExecutionTimeMs に拘束される。キャンセルされるとコマンドコンテキストは即座に無効化され、遅れて動き出した継続処理は ctx.fs / ctx.env / ctx.exec を使えない。スコープ終了時に必ず走らせたい後始末は ctx.executionScope.registerCleanup() に登録する。
いずれも JavaScript による再実装であり、ホストのバイナリは呼ばない。すべてのコマンドが --help に対応する。
js-exec(
javascript: true)python3 / python(
python: true)- パイプ・リダイレクト:
cmd1 | cmd2、>、>>、2>、2>&1、< - 連結:
&&、||、; - 変数展開:
$VAR、${VAR}、${VAR:-default}、位置パラメータ$1$@$# - glob:
*、?、[...] - 制御構文:
if/elif/else、for、while、until - 関数とローカル変数:
name() { ... }、local VAR=value - リンク: シンボリックリンク
ln -s、ハードリンクln
タイムゾーンは既定で UTC に固定され、ホストのタイムゾーンは漏れない。特定のゾーンを使いたい場合のみ、初期環境変数として TZ を渡す。
| モジュール | 役割 |
|---|---|
src/parser/ | 再帰下降パーサ。lexer.ts がヒアドキュメント・クォート・展開を含む bash 固有のトークナイズを担当し、parser.ts が専用サブパーサ(展開・複合コマンド)を束ねる |
src/interpreter/ | AST の実行エンジン。コマンドディスパッチ、単語展開、算術評価($((...)))、条件式([[ ]] / [ ])、制御構文、シェル組み込みを持つ |
src/commands/ | 外部コマンド相当の実装。1 コマンド 1 ディレクトリで、実装とテストを同居させる。レジストリパターンで登録される |
src/fs/ | 仮想ファイルシステム群と、実 FS を触る実装向けの共通セキュリティヘルパ |
src/network/ | 許可リストとリダイレクト検証を含む fetch 経路 |
src/security/ | グローバル遮蔽、多層防御、ファジング一式 |
AST トランスフォームプラグイン
スクリプトを AST にパースし、変換してから bash 文字列に戻せる。コマンドごとの stdout/stderr を採取する計装や、実行前のメタデータ抽出に使う。
import { Bash, BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "just-bash";
// 単体パイプライン — 出力は任意のシェルで実行できる
const pipeline = new BashTransformPipeline()
.use(new TeePlugin({ outputDir: "/tmp/logs" }))
.use(new CommandCollectorPlugin());
const result = pipeline.transform("echo hello | grep hello");
result.script; // 変換後の bash 文字列
result.metadata.commands; // ["echo", "grep", "tee"]
// 統合 API — exec() が変換を自動適用し metadata を返す
const bash = new Bash();
bash.registerTransformPlugin(new CommandCollectorPlugin());
const execResult = await bash.exec("echo hello | grep hello");
execResult.metadata?.commands; // ["echo", "grep"]
| 実装 | 読み取り元 | 書き込み先 | 用途 |
|---|---|---|---|
InMemoryFs(既定) | メモリ | メモリ | ディスクに一切触れない。初期ファイルは文字列・同期関数・非同期関数で与えられ、遅延読み込みされる |
OverlayFs | 実ディレクトリ | メモリ(copy-on-write) | 既存プロジェクトを読ませたいが変更は残したくないとき |
ReadWriteFs | 実ディレクトリ | 実ディレクトリ | エージェントに実際のディスク書き込みを許すとき |
MountableFs | マウントごと | マウントごと | 読み取り専用と読み書きを 1 つの名前空間に組み合わせる |
import { Bash } from "just-bash";
import { OverlayFs } from "just-bash/fs/overlay-fs";
const overlay = new OverlayFs({
root: "/path/to/project",
// copy-on-write のデータ量は実ファイル読み取りとは独立に制限される
maxMemoryBytes: 256 * 1024 * 1024,
});
const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() });
await env.exec("cat package.json"); // ディスクから読む
await env.exec('echo "modified" > package.json'); // メモリに留まる
import { Bash, MountableFs, InMemoryFs } from "just-bash";
import { OverlayFs } from "just-bash/fs/overlay-fs";
import { ReadWriteFs } from "just-bash/fs/read-write-fs";
const fs = new MountableFs({ base: new InMemoryFs() });
// 読み取り専用のナレッジベースをマウント
fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true }));
// 読み書き可能なワークスペースをマウント
fs.mount("/workspace", new ReadWriteFs({ root: "/path/to/workspace" }));
const bash = new Bash({ fs, cwd: "/workspace" });
await bash.exec("cp /mnt/knowledge/doc.txt ./"); // マウントをまたぐコピー
ReadWriteFs を使うときの前提: ルートはワークスペース用ディレクトリに向け、インストール済みの just-bash パッケージや他の信頼済みコードと分離する。ハードリンク経由の書き込みを防ぐため、複数リンクされた通常ファイルへの追記・メタデータ変更はコピーしてサンドボックス側のディレクトリエントリだけを差し替える。この暗黙のコピーは maxCopyOnWriteSize(既定 100 MB)で制限される。
OverlayFs と ReadWriteFs はどちらも allowSymlinks: false を既定値にしている。この状態では symlink() は EPERM を返し、実 FS のシンボリックリンクを経由するパスは拒否される。lstat() と readlink() は追跡せずに検査するだけなので動作し、readdir() はエントリを列挙するが、そこを経由する操作は失敗する。
判定は中央のゲート関数(ReadWriteFs の resolveAndValidate、OverlayFs の validateRealPath_)に集約され、ルートを除いた実パスと正規化パスを比較するだけで済むため追加 I/O が発生しない。新しい FS メソッドを足すときにこのゲートを通せば、保護は自動的に効く。読み書きは O_NOFOLLOW 付きの open() を使い、検証と I/O の間にリンクを差し替える TOCTOU 攻撃を塞いでいる。
| 機能 | 既定 | 有効化 | 備考 |
|---|---|---|---|
ネットワーク(curl) | 無効 | network オプション | 未設定時 curl は command not found になる |
JavaScript / TypeScript(js-exec) | 無効 | javascript: true | QuickJS WASM。メモリ上限 64 MB |
Python(python3) | 無効 | python: true | CPython を Emscripten でビルドしたもの |
SQLite(sqlite3) | 有効 | — | sql.js。ワーカースレッドで実行 |
ブラウザ環境ではコア(パース・実行・仮想 FS・組み込みコマンド)が動作する。python3 / python、sqlite3、js-exec、実 FS を触る OverlayFs / ReadWriteFs は Node.js が必要になる。
// URL プレフィックスを限定し、メソッドは GET / HEAD のみ(最も安全)
const env = new Bash({
network: {
allowedUrlPrefixes: [
"https://api.github.com/orgs/myorg/",
"https://api.example.com",
],
},
});
// 認証情報はヘッダ変換で注入する(シークレットはサンドボックスに入らない)
const env2 = new Bash({
network: {
allowedUrlPrefixes: [
"https://public-api.com",
{
url: "https://ai-gateway.vercel.sh",
transform: [{ headers: { Authorization: "Bearer secret" } }],
},
],
},
});
- オリジン一致: スキーム・ホスト・ポートが完全に一致する必要がある。
new URL()でパースしてから照合するためhttps://evil.com@good.comのような細工は通らない。 - パスプレフィックス: 指定した接頭辞で始まるパスだけを許可する。
- メソッド制限: 既定は
["GET", "HEAD"]。追加はallowedMethodsで明示する。 - リダイレクト保護: リダイレクトは手動で処理し、遷移のたびに許可リストへ照合する。
- ヘッダ変換: fetch 境界で注入され、同名のユーザ指定ヘッダを上書きする。リダイレクトごとに再評価されるため、変換対象外のホストへ認証情報が渡らない。
- レスポンス上限:
maxResponseSize(10 MB)を Content-Length とストリーミングの両方で強制する。
QuickJS の WASM サンドボックスで動く。require() と import は fs、path、child_process、process、os、url、assert、util、events、buffer、stream などの互換シムに解決される(fs はサンドボックスの仮想 FS を指す)。タイムアウトは既定 normal プロファイルで 30 秒、hardened プロファイルで 10 秒。ネットワークを有効化してもこの期限は延びない。
javascript.invokeTool を渡すと、スクリプト側からグローバルな tools プロキシ経由でホスト定義のツールを呼び出せる。フック自体は汎用で、素のマップ・MCP・Anthropic の tool-use など任意のツールフレームワークを接続できる。
const bash = new Bash({
javascript: {
// path: "math.add"(ドット区切り)
// argsJson: '{"a":1,"b":2}'(引数なしなら "")
// 戻り値: JSON 文字列。undefined なら ""
// throw: サンドボックス内の例外として伝播する
invokeTool: async (path, argsJson) => {
const args = argsJson ? JSON.parse(argsJson) : undefined;
if (path === "math.add") return JSON.stringify({ sum: args.a + args.b });
throw new Error(`Unknown tool: ${path}`);
},
},
});
CPython 3.13 を Emscripten でビルドした WASM を、実行ごとに新しいワーカースレッドで動かす。以前の Pyodide 方式と違い JS ブリッジのコードが存在しないため、import js は ModuleNotFoundError になる。Python レベルのサンドボックスを別途用意する必要がなく、隔離はビルド構成そのもので成立している。
- ビルド時に外した能力: 動的リンク(
dlopen)、ホスト FS のマウント種別、_ctypes、_emscripten_run_script、テスト用モジュール群。__emscripten_systemは -1 を返すようパッチされている - 実行時の制限: 30 秒タイムアウト(
maxPythonTimeoutMs)、実行ごとに新しいワーカー、標準ライブラリは.pycのみの zip としてメモリ FS に配置 - 遮断される経路: 生の TCP/UDP ソケットは到達不能、
os.system()は -1、subprocessとos.popen()はOSError、環境変数は明示的に渡したものだけ - HTTP: 独自の HTTPFS マウント経由でのみ可能で、許可リスト付き fetch を必要とする
リミットには 2 つのプロファイルがある。normal は互換性を優先した緩めの既定値、hardened は信用できないワークロード向けの厳しめのポリシー。プロファイルを選んだうえで、リソース単位に上書きできる。どちらのプロファイルでも全リソースが有界のままである点は変わらない。
const env = new Bash({
executionLimitProfile: "hardened",
executionLimits: {
maxCallDepth: 100, // 関数の再帰深度
maxCommandCount: 20000, // ネストした実行と共有
maxSourceBytes: 8 * 1024 * 1024, // パース前のシェルソース
maxFileSystemBytes: 256 * 1024 * 1024,// 既定 FS が保持するデータ
maxOutputSize: 32 * 1024 * 1024, // stdout + stderr の合計
maxArchiveBytes: 256 * 1024 * 1024, // 展開後のアーカイブ
maxDatabaseBytes: 128 * 1024 * 1024, // SQLite イメージ
maxExecutionTimeMs: 30_000, // 実行全体の壁時計期限
maxExtensionCleanupTimeMs: 25, // キャンセル応答の猶予
},
});
値の検証は Bash の構築時に行われ、不正な値はその場で弾かれる。エラーメッセージには、どのリソースに当たったかが含まれる。
| 攻撃ベクタ | 防御 |
|---|---|
無限ループ while true; do :; done | maxLoopIterations(10K) |
| 再帰によるフォークボム | maxCallDepth(100) |
ブレース展開ボム {1..999999} | maxBraceExpansionResults(10K) |
| コマンド置換の深いネスト | maxSubstitutionDepth(50) |
glob ボム **/* | maxGlobOperations(100K)、** セグメント数は最大 5 |
| トークンボム・巨大入力 | MAX_TOKENS(100K)、MAX_INPUT_SIZE(1MB)、パーサ深度 200 |
| ヒアドキュメントボム | maxHeredocSize(10MB) |
| 文字列・配列の肥大化 | maxStringLength(10MB)、maxArrayElements(100K) |
| ファイルディスクリプタ枯渇 | maxFileDescriptors(1024)を set 前に毎回検査 |
| 自己 source によるループ | maxSourceDepth(100) |
| 正規表現の破滅的バックトラック | re2js(線形時間の正規表現エンジン) |
脅威モデルは、スクリプトを書く相手(AI エージェントまたは利用者)とスクリプトが読み込む外部データの両方を、信頼度ゼロとして扱う。境界は 5 つに整理されている。
入力は完全に信用しない。どんな入力でもクラッシュ・ハング・情報漏洩を起こさずに処理する。
すべてのアクセスをサンドボックスのルートに閉じ込め、シンボリックリンクの追跡を遮断し、実 FS への書き込みを防ぐ。
既定で無効。有効化した場合も、すべての URL が許可リストを通過する必要がある。
子プロセスを起こさず、ホストの環境変数に触れず、Node.js 内部(process.binding、require、import())へ到達しない。
ユーザ由来のデータが JS オブジェクトのキーになる箇所(環境変数、AWK 変数、連想配列のキー)では、null プロトタイプのオブジェクトまたは Map を使い、プロトタイプ汚染を防ぐ。
防御は主系(primary)と副系(secondary)に分かれる。主系はアーキテクチャ上その経路が存在しないことに依拠し、副系はグローバルの差し替えによる補強なので、原理的な限界があると明記されている。
| 層 | 種別 | 対象 | 回避の難度 |
|---|---|---|---|
| アーキテクチャ(child_process を import しない) | primary | コード実行 | 非常に高い — 経路が存在しない |
| 仮想ファイルシステム | primary | ファイルアクセス | 高い — 中央ゲート関数に集約 |
| シンボリックリンク既定拒否 | primary | パストラバーサル | 高い — 追加 I/O なしのパス比較 |
| ネットワーク許可リスト | primary | ネットワーク | 高い — 既定オフ、リダイレクト毎に検証 |
| コマンドレジストリ | primary | コマンド実行 | 高い — 登録済み実装のみが動く |
| 実行リミット | primary | DoS | 高い — ループ・呼び出し・展開の各所で強制 |
| プロトタイプ汚染ガード | primary | データ整合性 | 中 — 新規コードでも規律が要る |
| パーサリミット | primary | パーサ DoS | 高い — トークン・深さ・サイズ・反復 |
| re2js 正規表現エンジン | primary | ReDoS | 高い — 線形時間を保証 |
| 多層防御(グローバル遮蔽) | secondary | JS への脱出 | 中 — 差し替えには原理的限界がある |
| プロセス情報の仮想化 | secondary | 情報漏洩 | 高い — 実値を出さない |
| エラーメッセージのサニタイズ | secondary | 情報漏洩 | 高い — FS 層と全エラー集約点で実施 |
副系の防御は AsyncLocalStorage による文脈判定つきのグローバル差し替えで実装され、信用しない非同期コンテキストからの呼び出しだけを拒否する。プロセス全体を巻き込む deny-all ローダは設置しない。解決された保護レベルは DefenseInDepthBox.getInstance().getStatus() で確認でき、文脈依存の動的 import() 保護が要件なら level: "full" を要求する。
| 分類 | 遮断対象 |
|---|---|
| コード実行 | Function コンストラクタ、eval、.constructor.constructor、async/generator 系コンストラクタ、setTimeout、WebAssembly、Proxy、Module._load / Module._resolveFilename |
| 情報漏洩 | process.env、process.argv、process.execPath、process.mainModule、Error.prepareStackTrace、hrtime / cpuUsage / memoryUsage、performance.now() |
| プロセス操作 | process.exit()、process.abort()、process.kill()、process.chdir()、process.setuid() / setgid() / umask() |
| プロトタイプ汚染 | __defineGetter__ / __defineSetter__ / __lookupGetter__ / __lookupSetter__ を throw、JSON と Math を凍結 |
| ワーカー限定 | process.stdout / stderr、process.send / channel / connected(メインスレッドはテストランナーやプロセス管理との干渉を避けるため対象外) |
ホスト情報は仮想化されている。PID と UID は既定値(pid=1、uid=1000)を返し、hostname / whoami / uname は汎用値を返す。FS 層の sanitizeError() は ErrnoException の .path を落とし、sanitizeErrorMessage() は OS のパス・node:internal/ のパス・スタックトレースを、コマンドディスパッチ・Bash.exec()・CLI・Python ブリッジの各集約点で除去する。
脅威モデルは、防げていない範囲も同じ粒度で書かれている。導入時に確認すべき主な項目は次のとおり。
| 項目 | リスク | 内容 |
|---|---|---|
動的 import() |
ランタイム依存 | import() は言語キーワードであり Proxy でも defineProperty でも捕捉できない。node:module.registerHooks() が使えるランタイムでは文脈付きローダフックで拒否できるが、そうでないランタイムでは best-effort に留まる |
| 事前取得された参照 | 低 | 多層防御を有効化する前に Function や eval の参照を掴んだコードは、その参照経由でプロキシを迂回できる。文書化・テスト済み |
globalThis の再代入 |
低 | サンドボックス内から遮蔽用プロキシを上書きされうる。副系防御である以上、破られない前提には立っていない |
| Python 実行面 | 中(オプトイン) | 意図的に受け入れているリスク。CPython の WASM 線形メモリは Node ワーカーの resourceLimits で確実には抑えられず、強い封じ込めにはプロセス/コンテナ隔離が要る |
| シグナル・ジョブ制御 | 低 | trap のセキュリティテストは限定的で、バックグラウンドジョブ制御は体系的に検証されていない。実プロセスを起こさないため影響は仮想モデル内に閉じる |
| Unicode・エンコーディング | 低 | 不正な UTF-8、ホモグラフ、RTL 上書き文字の体系的なテストがない。表示・混同に関わるもので、実行の脱出経路ではない |
fs・fetch・customCommands・トランスフォームプラグインは信頼済みとして扱われる。just-bash が守るのは信用できないスクリプトであって、信用できないホストではない。Node.js ランタイムと OS、依存パッケージのサプライチェーンも同様にランタイム防御の対象外で、ロックファイルや監査といったデプロイ側の対策に委ねられている。
また、実行は VM 隔離なしで行われる。コードベースはプロトタイプ汚染やホスト JS エンジン・FS への脱出に対して堅牢になるよう設計されているが、この構成自体が追加のリスクを持つことは README に明記されている。任意のバイナリを動かせる完全な VM が必要なら、別のサンドボックス製品を使う。
グローバルインストールすると、OverlayFS を使うサンドボックス CLI が入る。読み取りは実ファイルシステムから行われ、書き込みはすべてメモリに留まり実行後に破棄される。プロジェクトルートはサンドボックス内の ~/project 相当にマウントされるので、そのパスか既定の作業ディレクトリからの相対パスでファイルを参照する。
# インラインスクリプトを実行
just-bash -c 'ls -la && cat package.json | head -5'
# プロジェクトルートを指定して実行
just-bash -c 'grep -r "TODO" src/' --root /path/to/project
# 標準入力からスクリプトを渡す
echo 'find . -name "*.ts" | wc -l' | just-bash
# プログラムから扱いやすい JSON 出力
just-bash -c 'echo hello' --json
# {"stdout":"hello\n","stderr":"","exitCode":0}
オプションは -c <script>(引数からスクリプトを実行)、--root <path>(ルートディレクトリ、既定はカレント)、--cwd <path>(サンドボックス内の作業ディレクトリ)、-e, --errexit(最初のエラーで終了)、--json(JSON 出力)。
- GitHub: vercel-labs/just-bash — 本体リポジトリ(TypeScript モノレポ)。本ページは同リポジトリの README.md、パッケージの README.md、CLAUDE.md、THREAT_MODEL.md を出典としている
- npm: just-bash — パッケージ本体
- vercel-labs/bash-tool — just-bash を AI SDK のツールとして包むラッパ。エージェント向けの追加ガイドを
AGENTS.mdに持つ - AI SDK —
bash-toolが前提とするツール定義基盤 - Vercel Sandbox —
Sandboxクラスが API 互換を取っている実 VM 側のサービス