diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index 9f5e8b898..7bbaa155b 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -137,7 +137,7 @@ }, "python": { "label": "Python", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite", "interactive_terminal"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_typescript", "runtime_sqlite", "interactive_terminal"], "evidence": { "execution_limits": ["crates/bashkit-python/tests/_bashkit_categories.py#test_max_commands_limits_execution"], "cancellation": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_cancel_after_reset_still_works"], @@ -153,14 +153,14 @@ "mounts": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_live_mount_preserves_state_and_unmounts"], "snapshots": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_snapshot_roundtrip_from_snapshot_preserves_state_and_kwargs"], "runtime_python": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_python_enabled"], + "runtime_typescript": ["crates/bashkit-python/tests/test_tool_runtimes.py#test_runtimes_share_virtual_filesystem"], "runtime_sqlite": ["crates/bashkit-python/tests/test_sqlite.py#test_sqlite"], "interactive_terminal": ["crates/bashkit-python/tests/test_terminal.py#test_call_edits_file_in_vi"] }, "unsupported": { "stdin": "execute() accepts a command string and optional output callback, not top-level host stdin.", "host_call_suspension": "Python custom builtins await callbacks directly; no request and resume handle is exposed.", - "transport_hooks": "Python exposes network policy and credentials but no custom HttpTransport implementation hook.", - "runtime_typescript": "The Python package does not compile or expose the TypeScript runtime." + "transport_hooks": "Python exposes network policy and credentials but no custom HttpTransport implementation hook." } }, "napi": { diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml index 6fe2b803d..4d663d546 100644 --- a/crates/bashkit-python/Cargo.toml +++ b/crates/bashkit-python/Cargo.toml @@ -35,7 +35,7 @@ num-bigint = "^0.4.6" # none of which compile on wasm32-unknown-emscripten — see the wasm block # below and knowledge/runtimes/emscripten-wheels.md. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite", "terminal"] } +bashkit = { path = "../bashkit", features = ["python", "typescript", "realfs", "jq", "interop", "http_client", "sqlite", "terminal"] } bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } tokio = { workspace = true, features = ["rt-multi-thread"] } # pyo3-async-runtimes hard-pulls tokio `rt-multi-thread` + `net` (mio), which @@ -50,6 +50,6 @@ pyo3-async-runtimes = { workspace = true } # wasm-safe base features (sync, macros, io-util, rt, time) — current-thread # runtime only. See knowledge/runtimes/emscripten-wheels.md. [target.'cfg(target_arch = "wasm32")'.dependencies] -bashkit = { path = "../bashkit", features = ["python", "jq", "terminal"] } +bashkit = { path = "../bashkit", features = ["python", "typescript", "jq", "terminal"] } bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } tokio = { workspace = true } diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 006b9029a..9095148d1 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -84,6 +84,28 @@ bash = Bash( ) ``` +### Embedded Python and TypeScript + +Native wheels include both runtimes, disabled by default. Enable only those +your application needs: + +```python +from bashkit import BashTool + +# Python also requires host opt-in; scripts cannot set this permission. +tool = BashTool( + python=True, + typescript=True, + env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, +) +assert tool.execute_sync("python -c 'print(42)'").stdout == "42\n" +assert tool.execute_sync("ts -c '42'").stdout == "42\n" +``` + +LangChain's `BashkitTool` and deepagents' middleware/backend accept the same +flags. Runtime flags survive `reset()`; supply them explicitly when restoring +with `BashTool.from_snapshot()` or `BashTool.from_snapshot_keyed()`. + ### Live Output ```python diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index 50e40caaa..9b49fb655 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -1114,6 +1114,8 @@ class BashTool: custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, + python: bool = False, + typescript: bool = False, ) -> None: """Create a new BashTool. @@ -1145,6 +1147,14 @@ class BashTool: network: Optional outbound HTTP / network configuration. See ``Bash.__init__`` for accepted keys. Preserved across ``reset()`` and ``from_snapshot()``. + profile: Execution profile (Standard or Compatibility). + python: Register sandboxed Monty Python (default ``False``). + typescript: Register sandboxed ZapCode TypeScript (default ``False``). + + Embedded runtimes share the shell's virtual filesystem and stay enabled + after ``reset()``. Python additionally requires the host to provide + ``env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}`` at construction. + An agent script cannot grant this permission with ``export``. Example:: @@ -1155,6 +1165,13 @@ class BashTool: ... ) >>> print(tool.name) bash + >>> tool = BashTool( + ... python=True, + ... typescript=True, + ... env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + ... ) + >>> tool.execute_sync("python -c 'print(21)' && ts -c '21'").stdout + '21\\n21\\n' """ ... @@ -1412,6 +1429,8 @@ class BashTool: custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, + python: bool = False, + typescript: bool = False, ) -> BashTool: """Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs.""" ... @@ -1435,6 +1454,8 @@ class BashTool: custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, + python: bool = False, + typescript: bool = False, ) -> BashTool: """Create a new ``BashTool`` from HMAC-protected snapshot bytes.""" ... diff --git a/crates/bashkit-python/bashkit/deepagents.py b/crates/bashkit-python/bashkit/deepagents.py index 299b37598..241de71c6 100644 --- a/crates/bashkit-python/bashkit/deepagents.py +++ b/crates/bashkit-python/bashkit/deepagents.py @@ -159,6 +159,9 @@ def __init__( max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, + python: bool = False, + typescript: bool = False, + env: dict[str, str] | None = None, ): """Initialize middleware. @@ -169,6 +172,10 @@ def __init__( max_commands: Max commands (ignored if bash_tool provided) max_loop_iterations: Max iterations (ignored if bash_tool provided) timeout_seconds: Execution timeout in seconds (ignored if bash_tool provided) + python: Enable Monty Python (ignored if bash_tool provided) + typescript: Enable ZapCode TypeScript (ignored if bash_tool provided) + env: Initial environment for a new tool (ignored if bash_tool provided). + Python requires BASHKIT_ALLOW_INPROCESS_PYTHON=1 at construction. """ if bash_tool is not None: self._bash = bash_tool @@ -180,6 +187,9 @@ def __init__( max_commands=max_commands, max_loop_iterations=max_loop_iterations, timeout_seconds=timeout_seconds, + python=python, + typescript=typescript, + env=env, ) self._owns_bash = True @@ -226,6 +236,9 @@ def __init__( max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, + python: bool = False, + typescript: bool = False, + env: dict[str, str] | None = None, ): self._bash = NativeBashTool( username=username, @@ -233,6 +246,9 @@ def __init__( max_commands=max_commands, max_loop_iterations=max_loop_iterations, timeout_seconds=timeout_seconds, + python=python, + typescript=typescript, + env=env, ) self._id = f"bashkit-{uuid.uuid4().hex[:8]}" self._operation_timeout = ( diff --git a/crates/bashkit-python/bashkit/langchain.py b/crates/bashkit-python/bashkit/langchain.py index 402206763..310ca6442 100644 --- a/crates/bashkit-python/bashkit/langchain.py +++ b/crates/bashkit-python/bashkit/langchain.py @@ -93,6 +93,9 @@ def __init__( max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, + python: bool = False, + typescript: bool = False, + env: dict[str, str] | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, @@ -106,6 +109,9 @@ def __init__( max_commands=max_commands, max_loop_iterations=max_loop_iterations, timeout_seconds=timeout_seconds, + python=python, + typescript=typescript, + env=env, files=files, mounts=mounts, allowed_mount_paths=allowed_mount_paths, @@ -210,6 +216,9 @@ def create_bash_tool( max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, + python: bool = False, + typescript: bool = False, + env: dict[str, str] | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, @@ -259,6 +268,9 @@ def create_bash_tool( max_commands=max_commands, max_loop_iterations=max_loop_iterations, timeout_seconds=timeout_seconds, + python=python, + typescript=typescript, + env=env, files=files, mounts=mounts, allowed_mount_paths=allowed_mount_paths, diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 37f233ee4..ffa769568 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -5564,6 +5564,9 @@ pub struct BashTool { cwd: Option, /// Initial environment variables (mirrors `Bash::builder().env()`). env: Option>, + /// Opt-in runtimes, replayed for resets, snapshots, and tool metadata. + python: bool, + typescript: bool, /// Tracking list for `is_async` flag detection (used by session capture). /// Kept in sync with `host_registry` by `add_builtin` / `remove_builtin`. /// Wrapped in `Arc>` so post-construction registration works @@ -5607,6 +5610,12 @@ impl BashTool { fn build_live_builder(&self, py: Python<'_>) -> PyResult { let profile = self.profile.core(); let mut builder = Bash::builder().profile(profile.clone()); + if self.python { + builder = builder.python(); + } + if self.typescript { + builder = builder.typescript(); + } if let Some(ref username) = self.username { builder = builder.username(username); @@ -5661,6 +5670,12 @@ impl BashTool { fn build_rust_tool(&self) -> PyResult { let profile = self.profile.core(); let mut builder = RustBashTool::builder().profile(profile.clone()); + if self.python { + builder = builder.python(); + } + if self.typescript { + builder = builder.typescript(); + } if let Some(ref username) = self.username { builder = builder.username(username); @@ -5728,6 +5743,8 @@ impl BashTool { custom_builtins=None, network=None, profile=PyExecutionProfile::Standard, + python=false, + typescript=false, ))] fn new( py: Python<'_>, @@ -5746,9 +5763,17 @@ impl BashTool { custom_builtins: Option<&Bound<'_, PyDict>>, network: Option<&Bound<'_, PyDict>>, profile: PyExecutionProfile, + python: bool, + typescript: bool, ) -> PyResult { let core_profile = profile.core(); let mut builder = Bash::builder().profile(core_profile.clone()); + if python { + builder = builder.python(); + } + if typescript { + builder = builder.typescript(); + } if let Some(ref u) = username { builder = builder.username(u); @@ -5817,6 +5842,8 @@ impl BashTool { hostname, cwd, env, + python, + typescript, custom_builtins: Arc::new(StdMutex::new(custom_builtins)), runtime_env: Arc::new(StdMutex::new(Vec::new())), runtime_mounts: Arc::new(StdMutex::new(Vec::new())), @@ -6113,6 +6140,8 @@ impl BashTool { custom_builtins=None, network=None, profile=PyExecutionProfile::Standard, + python=false, + typescript=false, ))] #[allow(clippy::too_many_arguments)] fn from_snapshot( @@ -6133,6 +6162,8 @@ impl BashTool { custom_builtins: Option<&Bound<'_, PyDict>>, network: Option<&Bound<'_, PyDict>>, profile: PyExecutionProfile, + python: bool, + typescript: bool, ) -> PyResult { let tool = Self::new( py, @@ -6151,6 +6182,8 @@ impl BashTool { custom_builtins, network, profile, + python, + typescript, )?; tool.restore_snapshot(py, data)?; Ok(tool) @@ -6176,6 +6209,8 @@ impl BashTool { custom_builtins=None, network=None, profile=PyExecutionProfile::Standard, + python=false, + typescript=false, ))] #[allow(clippy::too_many_arguments)] fn from_snapshot_keyed( @@ -6197,6 +6232,8 @@ impl BashTool { custom_builtins: Option<&Bound<'_, PyDict>>, network: Option<&Bound<'_, PyDict>>, profile: PyExecutionProfile, + python: bool, + typescript: bool, ) -> PyResult { let tool = Self::new( py, @@ -6215,6 +6252,8 @@ impl BashTool { custom_builtins, network, profile, + python, + typescript, )?; tool.restore_snapshot_keyed(py, data, key)?; Ok(tool) diff --git a/crates/bashkit-python/tests/test_tool_runtimes.py b/crates/bashkit-python/tests/test_tool_runtimes.py new file mode 100644 index 000000000..40a8b3c9e --- /dev/null +++ b/crates/bashkit-python/tests/test_tool_runtimes.py @@ -0,0 +1,126 @@ +"""Opt-in tool runtimes share the VFS and survive interpreter rebuilds.""" + +import importlib.util + +import pytest + +from bashkit import BashTool + +PYTHON_ENV = {"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"} + + +@pytest.mark.parametrize("command", ["python", "python3", "ts", "node"]) +def test_runtimes_disabled_by_default(command): + result = BashTool().execute_sync(f"{command} -c '1 + 1'") + assert result.exit_code == 127 + + +@pytest.mark.parametrize( + ("runtime", "script", "other_script"), + [ + ("python", "python -c 'print(42)'", "ts -c '42'"), + ("typescript", "ts -c '42'", "python -c 'print(42)'"), + ], +) +def test_runtime_execution_metadata_and_reset(runtime, script, other_script): + tool = BashTool(env=PYTHON_ENV, **{runtime: True}) + for _ in range(2): + result = tool.execute_sync(script) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + assert tool.execute_sync(other_script).exit_code == 127 + assert runtime in tool.description().lower() + assert runtime in tool.help().lower() + tool.reset() + + +def test_runtimes_share_virtual_filesystem(): + tool = BashTool(python=True, typescript=True, env=PYTHON_ENV) + result = tool.execute_sync( + "echo 'hello' > /tmp/input.txt\n" + "ts -c \"await writeFile('/tmp/output.txt', " + "(await readFile('/tmp/input.txt')).trim().toUpperCase())\"\n" + "python -c 'from pathlib import Path; print(Path(\"/tmp/output.txt\").read_text())'" + ) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "HELLO" + assert tool.execute_sync("cat /tmp/output.txt").stdout == "HELLO" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("runtime", "script"), + [("python", "python -c 'print(42)'"), ("typescript", "ts -c '42'")], +) +async def test_runtime_async(runtime, script): + result = await BashTool(env=PYTHON_ENV, **{runtime: True}).execute(script) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +@pytest.mark.parametrize("keyed", [False, True]) +def test_snapshot_restore_runtime_flags(keyed): + original = BashTool(python=True, typescript=True, env=PYTHON_ENV) + assert original.execute_sync("echo '40 + 2' > /tmp/main.ts").exit_code == 0 + if keyed: + key = b"k" * 32 + restored = BashTool.from_snapshot_keyed( + original.snapshot_keyed(key), key, python=True, typescript=True, env=PYTHON_ENV + ) + else: + restored = BashTool.from_snapshot(original.snapshot(), python=True, typescript=True, env=PYTHON_ENV) + assert restored.execute_sync("ts /tmp/main.ts").stdout == "42\n" + assert restored.execute_sync("python -c 'print(42)'").stdout == "42\n" + + +def test_langchain_forwards_runtime_flags(): + if importlib.util.find_spec("langchain_core") is None: + pytest.skip("langchain-core not installed") + from bashkit.langchain import BashkitTool, create_bash_tool + + for factory in (BashkitTool, create_bash_tool): + tool = factory(python=True, typescript=True, env=PYTHON_ENV) + assert "python" in tool.description.lower() + assert "typescript" in tool.description.lower() + output = tool.invoke({"commands": "python -c 'print(21)' && ts -c '21'"}) + assert output == "21\n21\n" + + +def test_deepagents_forwards_runtime_flags(): + if importlib.util.find_spec("deepagents") is None: + pytest.skip("deepagents not installed") + from bashkit.deepagents import BashkitBackend, BashkitMiddleware, create_bash_middleware + + script = "python -c 'print(21)' && ts -c '21'" + for factory in (BashkitMiddleware, create_bash_middleware): + middleware = factory(python=True, typescript=True, env=PYTHON_ENV) + assert middleware.execute_sync(script) == "21\n21\n" + assert middleware.tools[0].invoke({"command": script}) == "21\n21" + assert "typescript" in middleware.tools[0].description.lower() + + backend = BashkitBackend(python=True, typescript=True, env=PYTHON_ENV) + assert backend.execute(script).output == "21\n21\n" + middleware = backend.create_middleware() + assert backend.execute("echo '42' > /tmp/main.ts").exit_code == 0 + assert middleware.execute_sync("ts /tmp/main.ts") == "42\n" + + +def test_python_gate_cannot_be_enabled_by_agent_script(): + tool = BashTool(python=True) + for _ in range(2): + result = tool.execute_sync("export BASHKIT_ALLOW_INPROCESS_PYTHON=1; python -c 'print(42)'") + assert result.exit_code == 1 + assert result.stdout == "" + assert "in-process Python disabled" in result.stderr + tool.reset() + + +@pytest.mark.parametrize( + ("runtime", "script"), + [("python", "python -c 'def'"), ("typescript", "ts -c 'const ='")], +) +def test_invalid_runtime_program_reports_failure(runtime, script): + result = BashTool(env=PYTHON_ENV, **{runtime: True}).execute_sync(script) + assert result.exit_code != 0 + assert result.stdout == "" + assert result.stderr diff --git a/docs/start-python.md b/docs/start-python.md index 61ade8280..09391f4a0 100644 --- a/docs/start-python.md +++ b/docs/start-python.md @@ -41,11 +41,32 @@ request), use the awaitable `execute()` instead: result = await bash.execute("echo hi | my_async_tool") ``` -## Embedded Python +## Embedded Python and TypeScript -Bashkit can also run Python *inside* the shell via the embedded Monty runtime, -enable it with `Bash(python=True)`. That is a different feature from embedding -Bashkit in your Python app; see the [Python builtin](../crates/bashkit/docs/python.md) guide. +Native wheels can run Python and TypeScript *inside* the shell. Both runtimes +are disabled by default; opt in with `python=True` and `typescript=True`. +Python also requires a host-supplied environment gate: + +```python +from bashkit import BashTool + +bash = BashTool( + python=True, + typescript=True, + env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, +) +assert bash.execute_sync("python -c 'print(42)'").stdout == "42\n" +assert bash.execute_sync("ts -c '42'").stdout == "42\n" +``` + +Scripts cannot grant Python permission by exporting the gate variable. Runtime +flags survive `reset()`; supply them again when constructing from a snapshot. +LangChain's `BashkitTool` and deepagents' middleware/backend accept the same +flags. + +This differs from embedding Bashkit in your Python application; see the +[Python builtin](../crates/bashkit/docs/python.md) and +[TypeScript builtin](../crates/bashkit/docs/typescript.md) guides. ## Examples diff --git a/knowledge/runtimes/emscripten-wheels.md b/knowledge/runtimes/emscripten-wheels.md index 1bd6dd4e7..742d0bd81 100644 --- a/knowledge/runtimes/emscripten-wheels.md +++ b/knowledge/runtimes/emscripten-wheels.md @@ -20,7 +20,7 @@ Implemented (reduced feature set). CI build + smoke test green; PyPI publish wir Bashkit ships an additional Python wheel targeting `wasm32-unknown-emscripten` (the Pyodide ABI), so `bashkit` runs **in the browser, JupyterLite, and other WASM hosts** with no native toolchain. Reduced-feature variant of the native -package: in-VFS shell plus embedded `jq` and Monty `python`, driven through +package: in-VFS shell plus embedded `jq`, Monty `python`, and ZapCode TypeScript, driven through blocking `execute_sync()`. Approach mirrors Pydantic's Emscripten-wheel recipe (). @@ -43,7 +43,7 @@ is confined to `crates/bashkit-python`. ## Feature surface: native vs wasm Present on both: `Bash`/`BashTool`/`ScriptedTool`, `execute_sync()` / -`execute_sync_or_throw()`, Monty `python=True`, `jq`, sync custom-builtin +`execute_sync_or_throw()`, Monty `python=True`, `BashTool(typescript=True)`, `jq`, sync custom-builtin callbacks, async custom-builtin callbacks (wasm: private-loop fallback only, no caller-loop). @@ -64,13 +64,13 @@ Per-target dependency split: ```toml [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bashkit = { path = "../bashkit", features = ["python","realfs","jq","interop","http_client","sqlite"] } +bashkit = { path = "../bashkit", features = ["python","typescript","realfs","jq","interop","http_client","sqlite"] } bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python","jq"] } tokio = { workspace = true, features = ["rt-multi-thread"] } pyo3-async-runtimes = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -bashkit = { path = "../bashkit", features = ["python","jq"] } +bashkit = { path = "../bashkit", features = ["python","typescript","jq"] } bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python","jq"] } tokio = { workspace = true } # wasm-safe base features only ``` diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index b708f49c5..c1d888eb3 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -85,6 +85,37 @@ text-oriented VFS helpers (`read_file`, `write_file`, `append_file`, `mkdir`, `ls`, `glob`); LLM metadata (`name`, `short_description`, `description()`, `help()`, `system_prompt()`, `input_schema()`, `output_schema()`, `version`). +### Embedded tool runtimes + +`BashTool(python=True, typescript=True)` registers Monty Python (`python`/`python3`) +and ZapCode TypeScript (`ts`/`node`) in the same VFS as Bash. Both flags default +to `False`. They configure the live interpreter and LLM metadata together, and +are replayed on `reset()`. The Python crate enables the existing TypeScript +core feature in native and Emscripten builds; command registration stays opt-in. +`Bash` keeps its existing Python configuration. + +Python's existing host permission remains separate from registration: +construct with `env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}`. The builtin +captures this grant at construction, so an agent's `export` cannot enable it. +Do not infer permission from untrusted script contents. See +[Python Builtin](python-builtin.md) and [ZapCode Runtime](zapcode-runtime.md). + +LangChain's `BashkitTool` / `create_bash_tool`, deepagents' `BashkitMiddleware` / +`create_bash_middleware`, and `BashkitBackend` forward `python`, `typescript`, +and `env` to their native tool. Backend-created middleware shares the configured +tool and VFS. Initial env forwarding lets the host grant Python explicitly +without weakening the runtime gate. + +Snapshot factories (`from_snapshot` / `from_snapshot_keyed`) accept the same +runtime flags and host env. Hosts supply them when reconstructing a tool; +snapshot bytes do not grant permission to enable Python. In-place restore and +reset retain the host's runtime selection. + +Regression evidence: `crates/bashkit-python/tests/test_tool_runtimes.py` covers +defaults, independent runtime selection, metadata, sync/async execution, shared +VFS, reset, keyed/unkeyed snapshot factories, framework forwarding, invalid +programs, and rejection of script-only Python opt-in. + ### Runtime host mutations `set_env()` and runtime `mount()` are recorded and replayed on every rebuild, diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 9f9865aae..0e0becbc4 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -38,7 +38,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Filesystem mounts beyond seeded virtual files | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | | Snapshot and restore of interpreter state | ✅ | — | — | — | ✅ | ✅ | ✅ | — | | Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | -| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | — | — | — | — | +| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | ✅ | — | — | — | | Explicit embedded SQLite opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | | In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | ✅ | — | — | @@ -94,7 +94,6 @@ A dash means the feature is intentionally unsupported and has a recorded reason - `stdin`: execute() accepts a command string and optional output callback, not top-level host stdin. - `host_call_suspension`: Python custom builtins await callbacks directly; no request and resume handle is exposed. - `transport_hooks`: Python exposes network policy and credentials but no custom HttpTransport implementation hook. -- `runtime_typescript`: The Python package does not compile or expose the TypeScript runtime. ### NAPI JavaScript diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 104861bc2..0aa4c0daf 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -590,7 +590,7 @@ Adds LLM-facing contract metadata (``description``, ``system_prompt``, ### Constructor ```python -BashTool(username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard) -> None +BashTool(username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> None ``` Create a new BashTool. @@ -611,6 +611,14 @@ Create a new BashTool. - **`readonly_filesystem`** — Deny all filesystem mutations after configured files and mounts are applied. - **`custom_builtins`** — Constructor-time Python callbacks exposed as bash builtins. Each callback receives a ``BuiltinContext`` (including a live ``fs`` handle to the virtual filesystem) and must return a stdout string, a ``BuiltinResult``, or await either. Async callbacks run on the caller's active asyncio loop for ``await execute()`` and on a private loop for ``execute_sync()``. - **`network`** — Optional outbound HTTP / network configuration. See ``Bash.__init__`` for accepted keys. Preserved across ``reset()`` and ``from_snapshot()``. +- **`profile`** — Execution profile (Standard or Compatibility). +- **`python`** — Register sandboxed Monty Python (default ``False``). +- **`typescript`** — Register sandboxed ZapCode TypeScript (default ``False``). + +Embedded runtimes share the shell's virtual filesystem and stay enabled +after ``reset()``. Python additionally requires the host to provide +``env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}`` at construction. +An agent script cannot grant this permission with ``export``. Example: @@ -622,6 +630,13 @@ Example: ... ) >>> print(tool.name) bash +>>> tool = BashTool( +... python=True, +... typescript=True, +... env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, +... ) +>>> tool.execute_sync("python -c 'print(21)' && ts -c '21'").stdout +'21\n21\n' ``` ### `execute` @@ -939,7 +954,7 @@ Restore interpreter state from bytes produced by ``snapshot_keyed()``. ### `from_snapshot` ```python -BashTool.from_snapshot(data: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard) -> BashTool +BashTool.from_snapshot(data: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> BashTool ``` Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs. @@ -947,7 +962,7 @@ Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs. ### `from_snapshot_keyed` ```python -BashTool.from_snapshot_keyed(data: bytes, key: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard) -> BashTool +BashTool.from_snapshot_keyed(data: bytes, key: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> BashTool ``` Create a new ``BashTool`` from HMAC-protected snapshot bytes. @@ -1908,7 +1923,7 @@ bashkit.langchain.PrivateAttr(*args, **kwargs) ### `create_bash_tool` ```python -bashkit.langchain.create_bash_tool(username: str | None = None, hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100000) -> BashkitTool +bashkit.langchain.create_bash_tool(username: str | None = None, hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, python: bool = False, typescript: bool = False, env: dict[str, str] | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100000) -> BashkitTool ``` Create a LangChain-compatible Bashkit tool.