just-bash

A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents.

1 — 概要

just-bash は、bash インタプリタと仮想ファイルシステムを TypeScript で実装した npm パッケージ。実プロセスを起動せず、パーサからコマンド実装まですべて JavaScript のコードとして動く。AI エージェントに bash を渡したいが、ホストのファイルシステムやネットワークには触れさせたくない、という用途を想定している。

約 79組み込みコマンド
0子プロセス起動
OFFネットワーク既定値
Node 20.18.1+動作要件

設計上の中心は「スクリプトは完全に信用しない」という前提。ファイルアクセスは仮想 FS に閉じ、ネットワークは既定で無効、Python と JavaScript の実行は明示的に有効化しないと存在しない。防御は 1 枚ではなく、アーキテクチャ・FS・ネットワーク・実行リミット・グローバル遮蔽の複数層で構成される。

ベータ版: パッケージの README は beta software と明記している。ライセンスは Apache-2.0。フル VM が必要な場合は Vercel Sandbox など別手段を使うよう案内されている。
2 — 導入と基本 API
bash
npm install just-bash
typescript
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() から見える。

オプション型説明
envRecord<string, string>この実行だけに適用する環境変数
cwdstringこの実行だけに適用する作業ディレクトリ
stdinstringスクリプトへ渡す標準入力
argsstring[]先頭コマンドへ直接渡す argv。シェルのパースを経由しない($1, $2 は変わらない)
replaceEnvboolean既存 env とマージせず空から始める(既定 false)
signalAbortSignal協調的キャンセル。次のステートメント境界で停止する
rawScriptboolean先頭空白の正規化をスキップする(既定 false)

defineCommand で独自コマンドを登録すると、パイプ・リダイレクト・その他のシェル機能とそのまま組み合わせて使える。コールバックは fs、cwd、env、stdin、解決済みの limits、サブコマンド用の exec を持つコンテキストを受け取る。

typescript
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() に登録する。
3 — 対応コマンドと構文

いずれも JavaScript による再実装であり、ホストのバイナリは呼ばない。すべてのコマンドが --help に対応する。

ファイル操作
cat, cp, file, ln, ls, mkdir, mv, readlink, rm, rmdir, split, stat, touch, tree
テキスト処理
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
データ処理
jq (JSON), sqlite3 (SQLite), xan (CSV), yq (YAML/XML/TOML/CSV)
圧縮・アーカイブ
gzip (gunzip, zcat), tar
環境・ナビゲーション
basename, cd, dirname, du, echo, env, export, find, hostname, printenv, pwd, tee
シェルユーティリティ
alias, bash, chmod, clear, date, expr, false, help, history, seq, sh, sleep, time, timeout, true, unalias, which, whoami
要設定
curl, html-to-markdown(ネットワーク設定時)
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 を渡す。

4 — 実行パイプラインと構成
入力スクリプト → Parser(Lexer) → AST → Interpreter → ExecResult
モジュール役割
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 を採取する計装や、実行前のメタデータ抽出に使う。

typescript
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"]
5 — ファイルシステムの選択肢
実装読み取り元書き込み先用途
InMemoryFs(既定)メモリメモリディスクに一切触れない。初期ファイルは文字列・同期関数・非同期関数で与えられ、遅延読み込みされる
OverlayFs実ディレクトリメモリ(copy-on-write)既存プロジェクトを読ませたいが変更は残したくないとき
ReadWriteFs実ディレクトリ実ディレクトリエージェントに実際のディスク書き込みを許すとき
MountableFsマウントごとマウントごと読み取り専用と読み書きを 1 つの名前空間に組み合わせる
typescript — OverlayFs
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'); // メモリに留まる
typescript — MountableFs
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 攻撃を塞いでいる。

6 — オプション機能
機能既定有効化備考
ネットワーク(curl)無効network オプション未設定時 curl は command not found になる
JavaScript / TypeScript(js-exec)無効javascript: trueQuickJS WASM。メモリ上限 64 MB
Python(python3)無効python: trueCPython を Emscripten でビルドしたもの
SQLite(sqlite3)有効—sql.js。ワーカースレッドで実行

ブラウザ環境ではコア(パース・実行・仮想 FS・組み込みコマンド)が動作する。python3 / python、sqlite3、js-exec、実 FS を触る OverlayFs / ReadWriteFs は Node.js が必要になる。

typescript
// 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 など任意のツールフレームワークを接続できる。

typescript
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 を必要とする
7 — 実行制限

リミットには 2 つのプロファイルがある。normal は互換性を優先した緩めの既定値、hardened は信用できないワークロード向けの厳しめのポリシー。プロファイルを選んだうえで、リソース単位に上書きできる。どちらのプロファイルでも全リソースが有界のままである点は変わらない。

typescript
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 :; donemaxLoopIterations(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(線形時間の正規表現エンジン)
8 — セキュリティ設計

脅威モデルは、スクリプトを書く相手(AI エージェントまたは利用者)とスクリプトが読み込む外部データの両方を、信頼度ゼロとして扱う。境界は 5 つに整理されている。

TB1 — スクリプト入力 → パーサ

入力は完全に信用しない。どんな入力でもクラッシュ・ハング・情報漏洩を起こさずに処理する。

TB2 — インタプリタ → ファイルシステム

すべてのアクセスをサンドボックスのルートに閉じ込め、シンボリックリンクの追跡を遮断し、実 FS への書き込みを防ぐ。

TB3 — インタプリタ → ネットワーク

既定で無効。有効化した場合も、すべての URL が許可リストを通過する必要がある。

TB4 — インタプリタ → ホストプロセス

子プロセスを起こさず、ホストの環境変数に触れず、Node.js 内部(process.binding、require、import())へ到達しない。

TB5 — データ → 変数・キー空間

ユーザ由来のデータが JS オブジェクトのキーになる箇所(環境変数、AWK 変数、連想配列のキー)では、null プロトタイプのオブジェクトまたは Map を使い、プロトタイプ汚染を防ぐ。

防御は主系(primary)と副系(secondary)に分かれる。主系はアーキテクチャ上その経路が存在しないことに依拠し、副系はグローバルの差し替えによる補強なので、原理的な限界があると明記されている。

層種別対象回避の難度
アーキテクチャ(child_process を import しない)primaryコード実行非常に高い — 経路が存在しない
仮想ファイルシステムprimaryファイルアクセス高い — 中央ゲート関数に集約
シンボリックリンク既定拒否primaryパストラバーサル高い — 追加 I/O なしのパス比較
ネットワーク許可リストprimaryネットワーク高い — 既定オフ、リダイレクト毎に検証
コマンドレジストリprimaryコマンド実行高い — 登録済み実装のみが動く
実行リミットprimaryDoS高い — ループ・呼び出し・展開の各所で強制
プロトタイプ汚染ガードprimaryデータ整合性中 — 新規コードでも規律が要る
パーサリミットprimaryパーサ DoS高い — トークン・深さ・サイズ・反復
re2js 正規表現エンジンprimaryReDoS高い — 線形時間を保証
多層防御(グローバル遮蔽)secondaryJS への脱出中 — 差し替えには原理的限界がある
プロセス情報の仮想化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 ブリッジの各集約点で除去する。

9 — 残存リスクと運用上の注意

脅威モデルは、防げていない範囲も同じ粒度で書かれている。導入時に確認すべき主な項目は次のとおり。

項目リスク内容
動的 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 相当にマウントされるので、そのパスか既定の作業ディレクトリからの相対パスでファイルを参照する。

bash
# インラインスクリプトを実行
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 側のサービス