fix(docker): gate cwd workspace mount behind config

Keep Docker sandboxes isolated by default. Add an explicit terminal.docker_mount_cwd_to_workspace opt-in, thread it through terminal/file environment creation, and document the security tradeoff and config.yaml workflow clearly.
This commit is contained in:
teknium1 2026-03-16 05:19:43 -07:00
parent 8cdbbcaaa2
commit 780ddd102b
11 changed files with 218 additions and 145 deletions

View file

@ -76,8 +76,9 @@ model:
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home) # - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
terminal: terminal:
backend: "local" backend: "local"
cwd: "." # For local backend: "." = current directory. Ignored for remote backends. cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
timeout: 180 timeout: 180
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
lifetime_seconds: 300 lifetime_seconds: 300
# sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext!
@ -107,6 +108,7 @@ terminal:
# timeout: 180 # timeout: 180
# lifetime_seconds: 300 # lifetime_seconds: 300
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" # docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# OPTION 4: Singularity/Apptainer container # OPTION 4: Singularity/Apptainer container

2
cli.py
View file

@ -165,6 +165,7 @@ def load_cli_config() -> Dict[str, Any]:
"modal_image": "python:3.11", "modal_image": "python:3.11",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_volumes": [], # host:container volume mounts for Docker backend "docker_volumes": [], # host:container volume mounts for Docker backend
"docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
}, },
"browser": { "browser": {
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
@ -330,6 +331,7 @@ def load_cli_config() -> Dict[str, Any]:
"container_disk": "TERMINAL_CONTAINER_DISK", "container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"sandbox_dir": "TERMINAL_SANDBOX_DIR", "sandbox_dir": "TERMINAL_SANDBOX_DIR",
# Persistent shell (non-local backends) # Persistent shell (non-local backends)
"persistent_shell": "TERMINAL_PERSISTENT_SHELL", "persistent_shell": "TERMINAL_PERSISTENT_SHELL",

View file

@ -118,6 +118,9 @@ DEFAULT_CONFIG = {
# Each entry is "host_path:container_path" (standard Docker -v syntax). # Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example: ["/home/user/projects:/workspace/projects", "/data:/data"] # Example: ["/home/user/projects:/workspace/projects", "/data:/data"]
"docker_volumes": [], "docker_volumes": [],
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
"docker_mount_cwd_to_workspace": False,
# Persistent shell — keep a long-lived bash shell across execute() calls # Persistent shell — keep a long-lived bash shell across execute() calls
# so cwd/env vars/shell variables survive between commands. # so cwd/env vars/shell variables survive between commands.
# Enabled by default for non-local backends (SSH); local is always opt-in # Enabled by default for non-local backends (SSH); local is always opt-in
@ -1407,6 +1410,7 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.cwd": "TERMINAL_CWD", "terminal.cwd": "TERMINAL_CWD",
"terminal.timeout": "TERMINAL_TIMEOUT", "terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",

View file

@ -115,3 +115,13 @@ class TestConfigYamlRouting:
set_config_value("terminal.docker_image", "python:3.12") set_config_value("terminal.docker_image", "python:3.12")
config = _read_config(_isolated_hermes_home) config = _read_config(_isolated_hermes_home)
assert "python:3.12" in config assert "python:3.12" in config
def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_hermes_home):
set_config_value("terminal.docker_mount_cwd_to_workspace", "true")
config = _read_config(_isolated_hermes_home)
env_content = _read_env(_isolated_hermes_home)
assert "docker_mount_cwd_to_workspace: 'true'" in config or "docker_mount_cwd_to_workspace: true" in config
assert (
"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content
or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content
)

View file

@ -19,6 +19,8 @@ def _make_dummy_env(**kwargs):
task_id=kwargs.get("task_id", "test-task"), task_id=kwargs.get("task_id", "test-task"),
volumes=kwargs.get("volumes", []), volumes=kwargs.get("volumes", []),
network=kwargs.get("network", True), network=kwargs.get("network", True),
host_cwd=kwargs.get("host_cwd"),
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
) )
@ -88,24 +90,16 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch):
def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path):
"""When host_cwd is provided, it should be auto-mounted to /workspace.""" """Opt-in docker cwd mounting should bind the host cwd to /workspace."""
import os
# Create a temp directory to simulate user's project directory
project_dir = tmp_path / "my-project" project_dir = tmp_path / "my-project"
project_dir.mkdir() project_dir.mkdir()
# Mock Docker availability
def _run_docker_version(*args, **kwargs): def _run_docker_version(*args, **kwargs):
return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="") return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="")
def _run_docker_create(*args, **kwargs):
return subprocess.CompletedProcess(args[0], 1, stdout="", stderr="storage-opt not supported")
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version) monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version)
# Mock the inner _Docker class to capture run_args
captured_run_args = [] captured_run_args = []
class MockInnerDocker: class MockInnerDocker:
@ -120,33 +114,21 @@ def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path):
MockInnerDocker, MockInnerDocker,
) )
# Create environment with host_cwd _make_dummy_env(
env = docker_env.DockerEnvironment(
image="python:3.11",
cwd="/workspace", cwd="/workspace",
timeout=60,
persistent_filesystem=False, # Non-persistent mode uses tmpfs, should be overridden
task_id="test-auto-mount",
volumes=[],
host_cwd=str(project_dir), host_cwd=str(project_dir),
auto_mount_cwd=True, auto_mount_cwd=True,
) )
# Check that the host_cwd was added as a volume mount
volume_mount = f"-v {project_dir}:/workspace"
run_args_str = " ".join(captured_run_args) run_args_str = " ".join(captured_run_args)
assert f"{project_dir}:/workspace" in run_args_str, f"Expected auto-mount in run_args: {run_args_str}" assert f"{project_dir}:/workspace" in run_args_str
def test_auto_mount_disabled_via_env(monkeypatch, tmp_path): def test_auto_mount_disabled_by_default(monkeypatch, tmp_path):
"""Auto-mount should be disabled when TERMINAL_DOCKER_NO_AUTO_MOUNT is set.""" """Host cwd should not be mounted unless the caller explicitly opts in."""
import os
project_dir = tmp_path / "my-project" project_dir = tmp_path / "my-project"
project_dir.mkdir() project_dir.mkdir()
monkeypatch.setenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "true")
def _run_docker_version(*args, **kwargs): def _run_docker_version(*args, **kwargs):
return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="") return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="")
@ -167,26 +149,18 @@ def test_auto_mount_disabled_via_env(monkeypatch, tmp_path):
MockInnerDocker, MockInnerDocker,
) )
env = docker_env.DockerEnvironment( _make_dummy_env(
image="python:3.11", cwd="/root",
cwd="/workspace",
timeout=60,
persistent_filesystem=False,
task_id="test-no-auto-mount",
volumes=[],
host_cwd=str(project_dir), host_cwd=str(project_dir),
auto_mount_cwd=True, auto_mount_cwd=False,
) )
# Check that the host_cwd was NOT added (because env var disabled it)
run_args_str = " ".join(captured_run_args) run_args_str = " ".join(captured_run_args)
assert f"{project_dir}:/workspace" not in run_args_str, f"Auto-mount should be disabled: {run_args_str}" assert f"{project_dir}:/workspace" not in run_args_str
def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path): def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path):
"""Auto-mount should be skipped if /workspace is already mounted via user volumes.""" """Explicit user volumes for /workspace should take precedence over cwd mount."""
import os
project_dir = tmp_path / "my-project" project_dir = tmp_path / "my-project"
project_dir.mkdir() project_dir.mkdir()
other_dir = tmp_path / "other" other_dir = tmp_path / "other"
@ -212,22 +186,52 @@ def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path
MockInnerDocker, MockInnerDocker,
) )
# User already configured a volume mount for /workspace _make_dummy_env(
env = docker_env.DockerEnvironment(
image="python:3.11",
cwd="/workspace", cwd="/workspace",
timeout=60,
persistent_filesystem=False,
task_id="test-workspace-exists",
volumes=[f"{other_dir}:/workspace"], # User explicitly mounted something to /workspace
host_cwd=str(project_dir), host_cwd=str(project_dir),
auto_mount_cwd=True, auto_mount_cwd=True,
volumes=[f"{other_dir}:/workspace"],
) )
# The user's explicit mount should be present
run_args_str = " ".join(captured_run_args) run_args_str = " ".join(captured_run_args)
assert f"{other_dir}:/workspace" in run_args_str assert f"{other_dir}:/workspace" in run_args_str
assert run_args_str.count(":/workspace") == 1
# But the auto-mount should NOT add a duplicate
assert run_args_str.count(":/workspace") == 1, f"Should only have one /workspace mount: {run_args_str}" def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path):
"""Persistent mode should still prefer the configured host cwd at /workspace."""
project_dir = tmp_path / "my-project"
project_dir.mkdir()
def _run_docker_version(*args, **kwargs):
return subprocess.CompletedProcess(args[0], 0, stdout="Docker version", stderr="")
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(docker_env.subprocess, "run", _run_docker_version)
captured_run_args = []
class MockInnerDocker:
container_id = "mock-container-persistent"
config = type("Config", (), {"executable": "/usr/bin/docker", "forward_env": [], "env": {}})()
def __init__(self, **kwargs):
captured_run_args.extend(kwargs.get("run_args", []))
monkeypatch.setattr(
"minisweagent.environments.docker.DockerEnvironment",
MockInnerDocker,
)
_make_dummy_env(
cwd="/workspace",
persistent_filesystem=True,
host_cwd=str(project_dir),
auto_mount_cwd=True,
task_id="test-persistent-auto-mount",
)
run_args_str = " ".join(captured_run_args)
assert f"{project_dir}:/workspace" in run_args_str
assert "/sandboxes/docker/test-persistent-auto-mount/workspace:/workspace" not in run_args_str

View file

@ -91,8 +91,8 @@ class TestCwdHandling:
"/home/ paths should be replaced for modal backend." "/home/ paths should be replaced for modal backend."
) )
def test_users_path_replaced_for_docker(self): def test_users_path_replaced_for_docker_by_default(self):
"""TERMINAL_CWD=/Users/... should be replaced with /root for docker.""" """Docker should keep host paths out of the sandbox unless explicitly enabled."""
with patch.dict(os.environ, { with patch.dict(os.environ, {
"TERMINAL_ENV": "docker", "TERMINAL_ENV": "docker",
"TERMINAL_CWD": "/Users/someone/projects", "TERMINAL_CWD": "/Users/someone/projects",
@ -100,8 +100,22 @@ class TestCwdHandling:
config = _tt_mod._get_env_config() config = _tt_mod._get_env_config()
assert config["cwd"] == "/root", ( assert config["cwd"] == "/root", (
f"Expected /root, got {config['cwd']}. " f"Expected /root, got {config['cwd']}. "
"/Users/ paths should be replaced for docker backend." "Host paths should be discarded for docker backend by default."
) )
assert config["host_cwd"] is None
assert config["docker_mount_cwd_to_workspace"] is False
def test_users_path_maps_to_workspace_for_docker_when_enabled(self):
"""Docker should map the host cwd into /workspace only when explicitly enabled."""
with patch.dict(os.environ, {
"TERMINAL_ENV": "docker",
"TERMINAL_CWD": "/Users/someone/projects",
"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE": "true",
}):
config = _tt_mod._get_env_config()
assert config["cwd"] == "/workspace"
assert config["host_cwd"] == "/Users/someone/projects"
assert config["docker_mount_cwd_to_workspace"] is True
def test_windows_path_replaced_for_modal(self): def test_windows_path_replaced_for_modal(self):
"""TERMINAL_CWD=C:\\Users\\... should be replaced for modal.""" """TERMINAL_CWD=C:\\Users\\... should be replaced for modal."""
@ -119,12 +133,27 @@ class TestCwdHandling:
# Remove TERMINAL_CWD so it uses default # Remove TERMINAL_CWD so it uses default
env = os.environ.copy() env = os.environ.copy()
env.pop("TERMINAL_CWD", None) env.pop("TERMINAL_CWD", None)
env.pop("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", None)
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
config = _tt_mod._get_env_config() config = _tt_mod._get_env_config()
assert config["cwd"] == "/root", ( assert config["cwd"] == "/root", (
f"Backend {backend}: expected /root default, got {config['cwd']}" f"Backend {backend}: expected /root default, got {config['cwd']}"
) )
def test_docker_default_cwd_maps_current_directory_when_enabled(self):
"""Docker should use /workspace when cwd mounting is explicitly enabled."""
with patch("tools.terminal_tool.os.getcwd", return_value="/home/user/project"):
with patch.dict(os.environ, {
"TERMINAL_ENV": "docker",
"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE": "true",
}, clear=False):
env = os.environ.copy()
env.pop("TERMINAL_CWD", None)
with patch.dict(os.environ, env, clear=True):
config = _tt_mod._get_env_config()
assert config["cwd"] == "/workspace"
assert config["host_cwd"] == "/home/user/project"
def test_local_backend_uses_getcwd(self): def test_local_backend_uses_getcwd(self):
"""Local backend should use os.getcwd(), not /root.""" """Local backend should use os.getcwd(), not /root."""
with patch.dict(os.environ, {"TERMINAL_ENV": "local"}, clear=False): with patch.dict(os.environ, {"TERMINAL_ENV": "local"}, clear=False):
@ -134,6 +163,31 @@ class TestCwdHandling:
config = _tt_mod._get_env_config() config = _tt_mod._get_env_config()
assert config["cwd"] == os.getcwd() assert config["cwd"] == os.getcwd()
def test_create_environment_passes_docker_host_cwd_and_flag(self, monkeypatch):
"""Docker host cwd and mount flag should reach DockerEnvironment."""
captured = {}
sentinel = object()
def _fake_docker_environment(**kwargs):
captured.update(kwargs)
return sentinel
monkeypatch.setattr(_tt_mod, "_DockerEnvironment", _fake_docker_environment)
env = _tt_mod._create_environment(
env_type="docker",
image="python:3.11",
cwd="/workspace",
timeout=60,
container_config={"docker_mount_cwd_to_workspace": True},
host_cwd="/home/user/project",
)
assert env is sentinel
assert captured["cwd"] == "/workspace"
assert captured["host_cwd"] == "/home/user/project"
assert captured["auto_mount_cwd"] is True
def test_ssh_preserves_home_paths(self): def test_ssh_preserves_home_paths(self):
"""SSH backend should NOT replace /home/ paths (they're valid remotely).""" """SSH backend should NOT replace /home/ paths (they're valid remotely)."""
with patch.dict(os.environ, { with patch.dict(os.environ, {

View file

@ -158,10 +158,6 @@ class DockerEnvironment(BaseEnvironment):
Persistence: when enabled, bind mounts preserve /workspace and /root Persistence: when enabled, bind mounts preserve /workspace and /root
across container restarts. across container restarts.
Auto-mount: when host_cwd is provided (the user's original working directory),
it is automatically bind-mounted to /workspace unless auto_mount_cwd=False
or the path is already covered by an explicit volume mount.
""" """
def __init__( def __init__(
@ -177,7 +173,7 @@ class DockerEnvironment(BaseEnvironment):
volumes: list = None, volumes: list = None,
network: bool = True, network: bool = True,
host_cwd: str = None, host_cwd: str = None,
auto_mount_cwd: bool = True, auto_mount_cwd: bool = False,
): ):
if cwd == "~": if cwd == "~":
cwd = "/root" cwd = "/root"
@ -220,30 +216,9 @@ class DockerEnvironment(BaseEnvironment):
# mode uses tmpfs (ephemeral, fast, gone on cleanup). # mode uses tmpfs (ephemeral, fast, gone on cleanup).
from tools.environments.base import get_sandbox_dir from tools.environments.base import get_sandbox_dir
self._workspace_dir: Optional[str] = None
self._home_dir: Optional[str] = None
if self._persistent:
sandbox = get_sandbox_dir() / "docker" / task_id
self._workspace_dir = str(sandbox / "workspace")
self._home_dir = str(sandbox / "home")
os.makedirs(self._workspace_dir, exist_ok=True)
os.makedirs(self._home_dir, exist_ok=True)
writable_args = [
"-v", f"{self._workspace_dir}:/workspace",
"-v", f"{self._home_dir}:/root",
]
else:
writable_args = [
"--tmpfs", "/workspace:rw,exec,size=10g",
"--tmpfs", "/home:rw,exec,size=1g",
"--tmpfs", "/root:rw,exec,size=1g",
]
# All containers get security hardening (capabilities dropped, no privilege
# escalation, PID limits). The container filesystem is writable so agents
# can install packages as needed.
# User-configured volume mounts (from config.yaml docker_volumes) # User-configured volume mounts (from config.yaml docker_volumes)
volume_args = [] volume_args = []
workspace_explicitly_mounted = False
for vol in (volumes or []): for vol in (volumes or []):
if not isinstance(vol, str): if not isinstance(vol, str):
logger.warning(f"Docker volume entry is not a string: {vol!r}") logger.warning(f"Docker volume entry is not a string: {vol!r}")
@ -253,31 +228,52 @@ class DockerEnvironment(BaseEnvironment):
continue continue
if ":" in vol: if ":" in vol:
volume_args.extend(["-v", vol]) volume_args.extend(["-v", vol])
if ":/workspace" in vol:
workspace_explicitly_mounted = True
else: else:
logger.warning(f"Docker volume '{vol}' missing colon, skipping") logger.warning(f"Docker volume '{vol}' missing colon, skipping")
# Auto-mount host CWD to /workspace when enabled (fixes #1445). host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else ""
# This allows users to run `cd my-project && hermes` and have Docker bind_host_cwd = (
# automatically mount their project directory into the container. auto_mount_cwd
# Disabled when: auto_mount_cwd=False, host_cwd is not a valid directory, and bool(host_cwd_abs)
# or /workspace is already covered by writable_args or a user volume. and os.path.isdir(host_cwd_abs)
auto_mount_disabled = os.getenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "").lower() in ("1", "true", "yes") and not workspace_explicitly_mounted
if host_cwd and auto_mount_cwd and not auto_mount_disabled: )
host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs):
if os.path.isdir(host_cwd_abs): logger.debug(f"Skipping docker cwd mount: host_cwd is not a valid directory: {host_cwd}")
# Check if /workspace is already being mounted by persistence or user config
workspace_already_mounted = any( self._workspace_dir: Optional[str] = None
":/workspace" in arg for arg in writable_args self._home_dir: Optional[str] = None
) or any( writable_args = []
":/workspace" in arg for arg in volume_args if self._persistent:
) sandbox = get_sandbox_dir() / "docker" / task_id
if not workspace_already_mounted: self._home_dir = str(sandbox / "home")
logger.info(f"Auto-mounting host CWD to /workspace: {host_cwd_abs}") os.makedirs(self._home_dir, exist_ok=True)
volume_args.extend(["-v", f"{host_cwd_abs}:/workspace"]) writable_args.extend([
else: "-v", f"{self._home_dir}:/root",
logger.debug(f"Skipping auto-mount: /workspace already mounted") ])
else: if not bind_host_cwd and not workspace_explicitly_mounted:
logger.debug(f"Skipping auto-mount: host_cwd is not a valid directory: {host_cwd}") self._workspace_dir = str(sandbox / "workspace")
os.makedirs(self._workspace_dir, exist_ok=True)
writable_args.extend([
"-v", f"{self._workspace_dir}:/workspace",
])
else:
if not bind_host_cwd and not workspace_explicitly_mounted:
writable_args.extend([
"--tmpfs", "/workspace:rw,exec,size=10g",
])
writable_args.extend([
"--tmpfs", "/home:rw,exec,size=1g",
"--tmpfs", "/root:rw,exec,size=1g",
])
if bind_host_cwd:
logger.info(f"Mounting configured host cwd to /workspace: {host_cwd_abs}")
volume_args = ["-v", f"{host_cwd_abs}:/workspace", *volume_args]
elif workspace_explicitly_mounted:
logger.debug("Skipping docker cwd mount: /workspace already mounted by user config")
logger.info(f"Docker volume_args: {volume_args}") logger.info(f"Docker volume_args: {volume_args}")
all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args

View file

@ -140,6 +140,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
container_config=container_config, container_config=container_config,
local_config=local_config, local_config=local_config,
task_id=task_id, task_id=task_id,
host_cwd=config.get("host_cwd"),
) )
with _env_lock: with _env_lock:

View file

@ -466,6 +466,8 @@ def _get_env_config() -> Dict[str, Any]:
default_image = "nikolaik/python-nodejs:python3.11-nodejs20" default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
env_type = os.getenv("TERMINAL_ENV", "local") env_type = os.getenv("TERMINAL_ENV", "local")
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes")
# Default cwd: local uses the host's current directory, everything # Default cwd: local uses the host's current directory, everything
# else starts in the user's home (~ resolves to whatever account # else starts in the user's home (~ resolves to whatever account
# is running inside the container/remote). # is running inside the container/remote).
@ -475,21 +477,25 @@ def _get_env_config() -> Dict[str, Any]:
default_cwd = "~" default_cwd = "~"
else: else:
default_cwd = "/root" default_cwd = "/root"
# Read TERMINAL_CWD but sanity-check it for container backends. # Read TERMINAL_CWD but sanity-check it for container backends.
# If the CWD looks like a host-local path that can't exist inside a # If Docker cwd passthrough is explicitly enabled, remap the host path to
# container/sandbox, fall back to the backend's own default. This # /workspace and track the original host path separately. Otherwise keep the
# catches the case where cli.py (or .env) leaked the host's CWD. # normal sandbox behavior and discard host paths.
# SSH is excluded since /home/ paths are valid on remote machines. cwd = os.getenv("TERMINAL_CWD", default_cwd)
raw_cwd = os.getenv("TERMINAL_CWD", default_cwd) host_cwd = None
cwd = raw_cwd host_prefixes = ("/Users/", "/home/", "C:\\", "C:/")
# Capture original host CWD for auto-mounting into containers (fixes #1445). if env_type == "docker" and mount_docker_cwd:
# Even when the container's working directory falls back to /root, we still docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd()
# want to auto-mount the user's host project directory to /workspace. candidate = os.path.abspath(os.path.expanduser(docker_cwd_source))
host_cwd = raw_cwd if raw_cwd and os.path.isdir(raw_cwd) else os.getcwd() if (
if env_type in ("modal", "docker", "singularity", "daytona") and cwd: any(candidate.startswith(p) for p in host_prefixes)
or (os.path.isabs(candidate) and os.path.isdir(candidate) and not candidate.startswith(("/workspace", "/root")))
):
host_cwd = candidate
cwd = "/workspace"
elif env_type in ("modal", "docker", "singularity", "daytona") and cwd:
# Host paths that won't exist inside containers # Host paths that won't exist inside containers
host_prefixes = ("/Users/", "/home/", "C:\\", "C:/")
if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd: if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd:
logger.info("Ignoring TERMINAL_CWD=%r for %s backend " logger.info("Ignoring TERMINAL_CWD=%r for %s backend "
"(host path won't exist in sandbox). Using %r instead.", "(host path won't exist in sandbox). Using %r instead.",
@ -503,7 +509,8 @@ def _get_env_config() -> Dict[str, Any]:
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
"cwd": cwd, "cwd": cwd,
"host_cwd": host_cwd, # Original host directory for auto-mounting into containers "host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
"timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"), "timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"),
"lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"), "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"),
# SSH-specific config # SSH-specific config
@ -544,7 +551,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
ssh_config: SSH connection config (for env_type="ssh") ssh_config: SSH connection config (for env_type="ssh")
container_config: Resource config for container backends (cpu, memory, disk, persistent) container_config: Resource config for container backends (cpu, memory, disk, persistent)
task_id: Task identifier for environment reuse and snapshot keying task_id: Task identifier for environment reuse and snapshot keying
host_cwd: Original host working directory (for auto-mounting into containers) host_cwd: Optional host working directory to bind into Docker when explicitly enabled
Returns: Returns:
Environment instance with execute() method Environment instance with execute() method
@ -568,6 +575,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
persistent_filesystem=persistent, task_id=task_id, persistent_filesystem=persistent, task_id=task_id,
volumes=volumes, volumes=volumes,
host_cwd=host_cwd, host_cwd=host_cwd,
auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
) )
elif env_type == "singularity": elif env_type == "singularity":
@ -957,6 +965,7 @@ def terminal_tool(
"container_disk": config.get("container_disk", 51200), "container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True), "container_persistent": config.get("container_persistent", True),
"docker_volumes": config.get("docker_volumes", []), "docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
} }
local_config = None local_config = None

View file

@ -79,6 +79,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | | `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` |
| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11`) | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11`) |
| `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) | | `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) |
| `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | Advanced opt-in: mount the launch cwd into Docker `/workspace` (`true`/`false`, default: `false`) |
| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path |
| `TERMINAL_MODAL_IMAGE` | Modal container image | | `TERMINAL_MODAL_IMAGE` | Modal container image |
| `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image |

View file

@ -453,7 +453,8 @@ terminal:
# Docker-specific settings # Docker-specific settings
docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
docker_volumes: # Share host directories with the container docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into /workspace.
docker_volumes: # Additional explicit host mounts
- "/home/user/projects:/workspace/projects" - "/home/user/projects:/workspace/projects"
- "/home/user/data:/data:ro" # :ro for read-only - "/home/user/data:/data:ro" # :ro for read-only
@ -520,41 +521,30 @@ This is useful for:
Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array). Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array).
### Docker Auto-Mount Current Directory ### Optional: Mount the Launch Directory into `/workspace`
When using the Docker backend, Hermes **automatically mounts your current working directory** to `/workspace` inside the container. This means you can: Docker sandboxes stay isolated by default. Hermes does **not** pass your current host working directory into the container unless you explicitly opt in.
```bash Enable it in `config.yaml`:
cd ~/projects/my-app
hermes ```yaml
# The agent can now see and edit files in ~/projects/my-app via /workspace terminal:
backend: docker
docker_mount_cwd_to_workspace: true
``` ```
No manual volume configuration needed — just `cd` to your project and run `hermes`. When enabled:
- if you launch Hermes from `~/projects/my-app`, that host directory is bind-mounted to `/workspace`
- the Docker backend starts in `/workspace`
- file tools and terminal commands both see the same mounted project
**How it works:** When disabled, `/workspace` stays sandbox-owned unless you explicitly mount something via `docker_volumes`.
- If you're in `/home/user/projects/my-app`, that directory is mounted to `/workspace`
- The container's working directory is set to `/workspace`
- Files you edit on the host are immediately visible to the agent, and vice versa
**Disabling auto-mount:** Security tradeoff:
- `false` preserves the sandbox boundary
- `true` gives the sandbox direct access to the directory you launched Hermes from
If you prefer the old behavior (empty `/workspace` with tmpfs or persistent sandbox), disable auto-mount: Use the opt-in only when you intentionally want the container to work on live host files.
```bash
export TERMINAL_DOCKER_NO_AUTO_MOUNT=true
```
**Precedence:**
Auto-mount is skipped when:
1. `TERMINAL_DOCKER_NO_AUTO_MOUNT=true` is set
2. You've explicitly configured a volume mount to `/workspace` in `docker_volumes`
3. `container_persistent: true` is set (persistent sandbox mode uses its own `/workspace`)
:::tip
Auto-mount is ideal for project-based work where you want the agent to operate on your actual files. For isolated sandboxing where the agent shouldn't access your filesystem, set `TERMINAL_DOCKER_NO_AUTO_MOUNT=true`.
:::
### Persistent Shell ### Persistent Shell