feat(tools): add microsandbox terminal backend

Adds microsandbox (https://microsandbox.dev) as a terminal backend alongside
docker/singularity/modal/daytona/ssh. Commands run inside a libkrun microVM
with its own kernel — meaningfully stronger isolation than the shared-kernel
container backends, without a cloud dependency or a daemon.

Backend shape is a thin wrapper around the msb CLI: one long-lived sandbox
per environment (msb create), command execution via msb exec, teardown via
msb stop + msb remove. Env-var filtering mirrors the Docker backend —
explicit docker-style microsandbox_forward_env / microsandbox_env lists,
skill passthroughs still filtered through _HERMES_PROVIDER_ENV_BLOCKLIST.

Files:
- tools/environments/microsandbox.py — new MicrosandboxEnvironment backend
- tools/terminal_tool.py — dispatch, container_config keys, image resolution
- hermes_cli/config.py — default microsandbox_* entries + env var sync
- cli-config.yaml.example — 'Option 7' documented config block
- tests/integration/test_microsandbox_terminal.py — skip-if-no-KVM
  integration tests for basic exec, filesystem, isolation, and the
  secret-leak regression

Host requirements: Linux with /dev/kvm readable (or macOS on Apple Silicon)
and msb on PATH or at MSB_PATH. Install: curl -fsSL https://install.microsandbox.dev | sh

Follow-up PR will wire this into the hermes_cli/setup.py wizard.
This commit is contained in:
Krzysztof Woś 2026-04-24 17:12:45 +09:00
parent 5dda4cab41
commit 1f2303d3e2
5 changed files with 531 additions and 4 deletions

View file

@ -873,6 +873,7 @@ def _get_env_config() -> Dict[str, Any]:
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
"microsandbox_image": os.getenv("TERMINAL_MICROSANDBOX_IMAGE", default_image),
"cwd": cwd,
"host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
@ -918,8 +919,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
Create an execution environment for sandboxed command execution.
Args:
env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh"
image: Docker/Singularity/Modal image name (ignored for local/ssh)
env_type: One of "local", "docker", "singularity", "modal", "daytona", "microsandbox", "ssh"
image: Docker/OCI/Singularity/Modal image name (ignored for local/ssh)
cwd: Working directory
timeout: Default command timeout
ssh_config: SSH connection config (for env_type="ssh")
@ -938,10 +939,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
volumes = cc.get("docker_volumes", [])
docker_forward_env = cc.get("docker_forward_env", [])
docker_env = cc.get("docker_env", {})
microsandbox_volumes = cc.get("microsandbox_volumes", [])
microsandbox_forward_env = cc.get("microsandbox_forward_env", [])
microsandbox_env = cc.get("microsandbox_env", {})
if env_type == "local":
return _LocalEnvironment(cwd=cwd, timeout=timeout)
elif env_type == "docker":
return _DockerEnvironment(
image=image, cwd=cwd, timeout=timeout,
@ -1022,6 +1026,18 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
persistent_filesystem=persistent, task_id=task_id,
)
elif env_type == "microsandbox":
# Lazy import so msb is only required when backend is selected.
from tools.environments.microsandbox import MicrosandboxEnvironment as _MicrosandboxEnvironment
return _MicrosandboxEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=cpu, memory=memory, disk=disk,
persistent_filesystem=persistent, task_id=task_id,
volumes=microsandbox_volumes,
forward_env=microsandbox_forward_env,
env=microsandbox_env,
)
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
@ -1462,6 +1478,8 @@ def terminal_tool(
image = overrides.get("modal_image") or config["modal_image"]
elif env_type == "daytona":
image = overrides.get("daytona_image") or config["daytona_image"]
elif env_type == "microsandbox":
image = overrides.get("microsandbox_image") or config["microsandbox_image"]
else:
image = ""