From 780ddd102b1a8c8d1231ad44fd2035ced289d124 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Mon, 16 Mar 2026 05:19:43 -0700 Subject: [PATCH] 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. --- cli-config.yaml.example | 4 +- cli.py | 2 + hermes_cli/config.py | 4 + tests/hermes_cli/test_set_config_value.py | 10 ++ tests/tools/test_docker_environment.py | 100 +++++++++--------- tests/tools/test_modal_sandbox_fixes.py | 60 ++++++++++- tools/environments/docker.py | 94 ++++++++-------- tools/file_tools.py | 1 + tools/terminal_tool.py | 39 ++++--- .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/configuration.md | 48 ++++----- 11 files changed, 218 insertions(+), 145 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 7bc2c4908..c493a309d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -76,8 +76,9 @@ model: # - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home) terminal: 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 + docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! @@ -107,6 +108,7 @@ terminal: # timeout: 180 # lifetime_seconds: 300 # 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 diff --git a/cli.py b/cli.py index 470186572..aa888fd6a 100755 --- a/cli.py +++ b/cli.py @@ -165,6 +165,7 @@ def load_cli_config() -> Dict[str, Any]: "modal_image": "python:3.11", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", "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": { "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_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "sandbox_dir": "TERMINAL_SANDBOX_DIR", # Persistent shell (non-local backends) "persistent_shell": "TERMINAL_PERSISTENT_SHELL", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f78131308..dbb37b284 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -118,6 +118,9 @@ DEFAULT_CONFIG = { # Each entry is "host_path:container_path" (standard Docker -v syntax). # Example: ["/home/user/projects:/workspace/projects", "/data:/data"] "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 # so cwd/env vars/shell variables survive between commands. # 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.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", + "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "terminal.cwd": "TERMINAL_CWD", "terminal.timeout": "TERMINAL_TIMEOUT", "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 52a9d1a6c..4eae64d6e 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -115,3 +115,13 @@ class TestConfigYamlRouting: set_config_value("terminal.docker_image", "python:3.12") config = _read_config(_isolated_hermes_home) 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 + ) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 3ed297b59..499ebcd43 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -19,6 +19,8 @@ def _make_dummy_env(**kwargs): task_id=kwargs.get("task_id", "test-task"), volumes=kwargs.get("volumes", []), 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): - """When host_cwd is provided, it should be auto-mounted to /workspace.""" - import os - - # Create a temp directory to simulate user's project directory + """Opt-in docker cwd mounting should bind the host cwd to /workspace.""" project_dir = tmp_path / "my-project" project_dir.mkdir() - # Mock Docker availability def _run_docker_version(*args, **kwargs): 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.subprocess, "run", _run_docker_version) - # Mock the inner _Docker class to capture run_args captured_run_args = [] class MockInnerDocker: @@ -120,33 +114,21 @@ def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): MockInnerDocker, ) - # Create environment with host_cwd - env = docker_env.DockerEnvironment( - image="python:3.11", + _make_dummy_env( 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), 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) - 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): - """Auto-mount should be disabled when TERMINAL_DOCKER_NO_AUTO_MOUNT is set.""" - import os - +def test_auto_mount_disabled_by_default(monkeypatch, tmp_path): + """Host cwd should not be mounted unless the caller explicitly opts in.""" project_dir = tmp_path / "my-project" project_dir.mkdir() - monkeypatch.setenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "true") - def _run_docker_version(*args, **kwargs): 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, ) - env = docker_env.DockerEnvironment( - image="python:3.11", - cwd="/workspace", - timeout=60, - persistent_filesystem=False, - task_id="test-no-auto-mount", - volumes=[], + _make_dummy_env( + cwd="/root", 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) - 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): - """Auto-mount should be skipped if /workspace is already mounted via user volumes.""" - import os - + """Explicit user volumes for /workspace should take precedence over cwd mount.""" project_dir = tmp_path / "my-project" project_dir.mkdir() other_dir = tmp_path / "other" @@ -212,22 +186,52 @@ def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path MockInnerDocker, ) - # User already configured a volume mount for /workspace - env = docker_env.DockerEnvironment( - image="python:3.11", + _make_dummy_env( 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), auto_mount_cwd=True, + volumes=[f"{other_dir}:/workspace"], ) - # The user's explicit mount should be present run_args_str = " ".join(captured_run_args) 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 diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 6da25216b..49c306231 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -91,8 +91,8 @@ class TestCwdHandling: "/home/ paths should be replaced for modal backend." ) - def test_users_path_replaced_for_docker(self): - """TERMINAL_CWD=/Users/... should be replaced with /root for docker.""" + def test_users_path_replaced_for_docker_by_default(self): + """Docker should keep host paths out of the sandbox unless explicitly enabled.""" with patch.dict(os.environ, { "TERMINAL_ENV": "docker", "TERMINAL_CWD": "/Users/someone/projects", @@ -100,8 +100,22 @@ class TestCwdHandling: config = _tt_mod._get_env_config() assert config["cwd"] == "/root", ( 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): """TERMINAL_CWD=C:\\Users\\... should be replaced for modal.""" @@ -119,12 +133,27 @@ class TestCwdHandling: # Remove TERMINAL_CWD so it uses default env = os.environ.copy() env.pop("TERMINAL_CWD", None) + env.pop("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", None) with patch.dict(os.environ, env, clear=True): config = _tt_mod._get_env_config() assert config["cwd"] == "/root", ( 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): """Local backend should use os.getcwd(), not /root.""" with patch.dict(os.environ, {"TERMINAL_ENV": "local"}, clear=False): @@ -134,6 +163,31 @@ class TestCwdHandling: config = _tt_mod._get_env_config() 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): """SSH backend should NOT replace /home/ paths (they're valid remotely).""" with patch.dict(os.environ, { diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 1c95f7b34..ec6d8b30c 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -158,10 +158,6 @@ class DockerEnvironment(BaseEnvironment): Persistence: when enabled, bind mounts preserve /workspace and /root 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__( @@ -177,7 +173,7 @@ class DockerEnvironment(BaseEnvironment): volumes: list = None, network: bool = True, host_cwd: str = None, - auto_mount_cwd: bool = True, + auto_mount_cwd: bool = False, ): if cwd == "~": cwd = "/root" @@ -220,30 +216,9 @@ class DockerEnvironment(BaseEnvironment): # mode uses tmpfs (ephemeral, fast, gone on cleanup). 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) volume_args = [] + workspace_explicitly_mounted = False for vol in (volumes or []): if not isinstance(vol, str): logger.warning(f"Docker volume entry is not a string: {vol!r}") @@ -253,31 +228,52 @@ class DockerEnvironment(BaseEnvironment): continue if ":" in vol: volume_args.extend(["-v", vol]) + if ":/workspace" in vol: + workspace_explicitly_mounted = True else: logger.warning(f"Docker volume '{vol}' missing colon, skipping") - # Auto-mount host CWD to /workspace when enabled (fixes #1445). - # This allows users to run `cd my-project && hermes` and have Docker - # automatically mount their project directory into the container. - # Disabled when: auto_mount_cwd=False, host_cwd is not a valid directory, - # or /workspace is already covered by writable_args or a user volume. - auto_mount_disabled = os.getenv("TERMINAL_DOCKER_NO_AUTO_MOUNT", "").lower() in ("1", "true", "yes") - if host_cwd and auto_mount_cwd and not auto_mount_disabled: - host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) - if os.path.isdir(host_cwd_abs): - # Check if /workspace is already being mounted by persistence or user config - workspace_already_mounted = any( - ":/workspace" in arg for arg in writable_args - ) or any( - ":/workspace" in arg for arg in volume_args - ) - if not workspace_already_mounted: - logger.info(f"Auto-mounting host CWD to /workspace: {host_cwd_abs}") - volume_args.extend(["-v", f"{host_cwd_abs}:/workspace"]) - else: - logger.debug(f"Skipping auto-mount: /workspace already mounted") - else: - logger.debug(f"Skipping auto-mount: host_cwd is not a valid directory: {host_cwd}") + host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else "" + bind_host_cwd = ( + auto_mount_cwd + and bool(host_cwd_abs) + and os.path.isdir(host_cwd_abs) + and not workspace_explicitly_mounted + ) + if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs): + logger.debug(f"Skipping docker cwd mount: host_cwd is not a valid directory: {host_cwd}") + + self._workspace_dir: Optional[str] = None + self._home_dir: Optional[str] = None + writable_args = [] + if self._persistent: + sandbox = get_sandbox_dir() / "docker" / task_id + self._home_dir = str(sandbox / "home") + os.makedirs(self._home_dir, exist_ok=True) + writable_args.extend([ + "-v", f"{self._home_dir}:/root", + ]) + if not bind_host_cwd and not workspace_explicitly_mounted: + 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}") all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args diff --git a/tools/file_tools.py b/tools/file_tools.py index 98ea15bd4..ddcfcd567 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -140,6 +140,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: container_config=container_config, local_config=local_config, task_id=task_id, + host_cwd=config.get("host_cwd"), ) with _env_lock: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index a9326f3ec..49a82e249 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -466,6 +466,8 @@ def _get_env_config() -> Dict[str, Any]: default_image = "nikolaik/python-nodejs:python3.11-nodejs20" 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 # else starts in the user's home (~ resolves to whatever account # is running inside the container/remote). @@ -475,21 +477,25 @@ def _get_env_config() -> Dict[str, Any]: default_cwd = "~" else: default_cwd = "/root" - + # 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 - # container/sandbox, fall back to the backend's own default. This - # catches the case where cli.py (or .env) leaked the host's CWD. - # SSH is excluded since /home/ paths are valid on remote machines. - raw_cwd = os.getenv("TERMINAL_CWD", default_cwd) - cwd = raw_cwd - # Capture original host CWD for auto-mounting into containers (fixes #1445). - # Even when the container's working directory falls back to /root, we still - # want to auto-mount the user's host project directory to /workspace. - host_cwd = raw_cwd if raw_cwd and os.path.isdir(raw_cwd) else os.getcwd() - if env_type in ("modal", "docker", "singularity", "daytona") and cwd: + # If Docker cwd passthrough is explicitly enabled, remap the host path to + # /workspace and track the original host path separately. Otherwise keep the + # normal sandbox behavior and discard host paths. + cwd = os.getenv("TERMINAL_CWD", default_cwd) + host_cwd = None + host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") + if env_type == "docker" and mount_docker_cwd: + docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd() + candidate = os.path.abspath(os.path.expanduser(docker_cwd_source)) + if ( + 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_prefixes = ("/Users/", "/home/", "C:\\", "C:/") if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd: logger.info("Ignoring TERMINAL_CWD=%r for %s backend " "(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), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), "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"), "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"), # 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") container_config: Resource config for container backends (cpu, memory, disk, persistent) 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: 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, volumes=volumes, host_cwd=host_cwd, + auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False), ) elif env_type == "singularity": @@ -957,6 +965,7 @@ def terminal_tool( "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), "docker_volumes": config.get("docker_volumes", []), + "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), } local_config = None diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 2b462e186..daaad87bc 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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_DOCKER_IMAGE` | Docker image (default: `python:3.11`) | | `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_MODAL_IMAGE` | Modal container image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 9a673bc7f..ab5e47ef6 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -453,7 +453,8 @@ terminal: # Docker-specific settings 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/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). -### 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 -cd ~/projects/my-app -hermes -# The agent can now see and edit files in ~/projects/my-app via /workspace +Enable it in `config.yaml`: + +```yaml +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:** -- 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 +When disabled, `/workspace` stays sandbox-owned unless you explicitly mount something via `docker_volumes`. -**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: - -```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`. -::: +Use the opt-in only when you intentionally want the container to work on live host files. ### Persistent Shell