diff --git a/cli.py b/cli.py index ec6d2b51bc..9fe19f1296 100644 --- a/cli.py +++ b/cli.py @@ -293,6 +293,7 @@ _TERMINAL_ENV_MAPPINGS = { "ssh_host", "ssh_user", "ssh_port", "ssh_key", "container_cpu", "container_memory", "container_disk", "container_persistent", "docker_volumes", "docker_env", "docker_extra_args", "docker_shm_size", "docker_mount_cwd_to_workspace", "docker_network", "docker_run_as_host_user", + "docker_snap_compat", "docker_persist_across_processes", "docker_shared_container_key", "docker_orphan_reaper", "sandbox_dir", "persistent_shell", ) diff --git a/gateway/run.py b/gateway/run.py index f59ae79a82..0a62103a77 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1841,6 +1841,7 @@ def _bridge_terminal_config_to_env(_terminal_cfg: dict) -> None: "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", + "docker_snap_compat": "TERMINAL_DOCKER_SNAP_COMPAT", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e384a4dca4..c6e9d5a717 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2055,7 +2055,7 @@ TERMINAL_CONFIG_ENV_MAP = { "daytona_image", "vercel_runtime", "ssh_host", "ssh_user", "ssh_port", "ssh_key", "container_cpu", "container_memory", "container_disk", "container_persistent", "docker_volumes", "docker_env", "docker_mount_cwd_to_workspace", "docker_network", - "docker_extra_args", "docker_shm_size", "docker_run_as_host_user", + "docker_extra_args", "docker_shm_size", "docker_run_as_host_user", "docker_snap_compat", "docker_persist_across_processes", "docker_shared_container_key", "docker_orphan_reaper", "sandbox_dir", "persistent_shell")}} diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 234ef5dcc1..1f98ab6024 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -332,6 +332,10 @@ DEFAULT_CONFIG = { # default for images whose entrypoints must start as root (e.g. the bundled Hermes image, # which drops to `hermes` via s6-setuidgid). When on, SETUID/SETGID caps are omitted. "docker_run_as_host_user": False, + # Snap-packaged Docker under AppArmor (Ubuntu cloud images; LP#1908448) refuses to exec + # anything under `--init` or `--security-opt no-new-privileges` ("operation not + # permitted"). True drops those two flags; every other hardening stays. See #9730. + "docker_snap_compat": False, # Trusted profiles sharing one Docker container identity; empty = per-profile boundary. "docker_shared_container_key": "", # Keep a long-lived bash shell across execute() calls so cwd/env/shell variables survive. diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 99761a52bc..c53ca663cc 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -239,7 +239,6 @@ _BASE_SECURITY_ARGS = [ "--cap-add", "DAC_OVERRIDE", "--cap-add", "CHOWN", "--cap-add", "FOWNER", - "--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,nosuid,size=512m", "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m"] @@ -295,9 +294,15 @@ _PRIVDROP_CAP_ARGS = ["--cap-add", "SETUID", "--cap-add", "SETGID"] _S6_INIT_ENTRYPOINTS = ("/init", "/package/admin/s6-overlay/command/init") -def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]: - """Security/cap/tmpfs args for the privilege mode; ``run_exec`` mounts /run exec for s6 images.""" - args = list(_BASE_SECURITY_ARGS) + list(_RUN_TMPFS_EXEC if run_exec else _RUN_TMPFS_NOEXEC) +_NO_NEW_PRIVILEGES_ARGS = ["--security-opt", "no-new-privileges"] + + +def _build_security_args(run_as_host_user: bool, run_exec: bool = False, snap_compat: bool = False) -> list[str]: + """Security/cap/tmpfs args for the privilege mode; ``run_exec`` mounts /run exec for s6 images. + ``snap_compat`` drops no-new-privileges: snap-packaged Docker's AppArmor profile turns it into + "exec: operation not permitted" for every process in the container (#9730, LP#1908448).""" + args = list(_BASE_SECURITY_ARGS) + ([] if snap_compat else list(_NO_NEW_PRIVILEGES_ARGS)) + args += list(_RUN_TMPFS_EXEC if run_exec else _RUN_TMPFS_NOEXEC) return args if run_as_host_user else args + list(_PRIVDROP_CAP_ARGS) @@ -501,7 +506,8 @@ class DockerEnvironment(BaseEnvironment): extra_args: list = None, persist_across_processes: bool = True, shm_size: str = _DEFAULT_SHM_SIZE, - shared_container_key: str = ""): + shared_container_key: str = "", + snap_compat: bool = False): if cwd == "~": cwd = "/root" super().__init__(cwd=cwd, timeout=timeout) @@ -547,7 +553,12 @@ class DockerEnvironment(BaseEnvironment): "Docker: image %s uses /init (s6-overlay) as entrypoint — " "skipping --init and mounting /run with exec.", image) - security_args = _build_security_args(run_as_host_user and bool(user_args), run_exec=image_uses_s6_init) + security_args = _build_security_args( + run_as_host_user and bool(user_args), run_exec=image_uses_s6_init, snap_compat=snap_compat) + self._snap_compat = snap_compat + if snap_compat: + logger.warning( + "docker_snap_compat: running without --init and no-new-privileges (snap Docker under AppArmor)") logger.info("Docker volume_args: %s", volume_args) # docker_extra_args go last so they can override defaults. @@ -751,7 +762,7 @@ class DockerEnvironment(BaseEnvironment): # own /init PID 1, so adding --init there creates two competing inits and breaks startup # (#34628). self._docker_exe, "run", "-d", - *([] if self._image_uses_s6_init else ["--init"]), + *([] if self._image_uses_s6_init or self._snap_compat else ["--init"]), "--name", name, *label_args, "-w", workdir, diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 65f5e7b908..6aed41dc62 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -654,6 +654,7 @@ def _get_env_config() -> Dict[str, Any]: "docker_volumes": docker_volumes, "docker_env": docker_env, "docker_run_as_host_user": _tenv_bool("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false"), + "docker_snap_compat": _tenv_bool("TERMINAL_DOCKER_SNAP_COMPAT", "false"), "docker_network": _tenv_bool("TERMINAL_DOCKER_NETWORK", "true"), "docker_extra_args": docker_extra_args, "docker_shm_size": docker_shm_size, diff --git a/tools/terminal_tool_backends.py b/tools/terminal_tool_backends.py index 8eb38cb67c..63a3dd1925 100644 --- a/tools/terminal_tool_backends.py +++ b/tools/terminal_tool_backends.py @@ -40,7 +40,7 @@ _CONTAINER_KEYS = ( ("docker_volumes", []), ("docker_mount_cwd_to_workspace", False), ("docker_forward_env", []), ("docker_env", {}), ("docker_run_as_host_user", False), ("docker_extra_args", []), ("docker_shm_size", "1g"), ("docker_network", True), ("docker_persist_across_processes", True), - ("docker_shared_container_key", ""), ("docker_orphan_reaper", True), + ("docker_shared_container_key", ""), ("docker_orphan_reaper", True), ("docker_snap_compat", False), ) _DOCKER_KWARGS = ( ("volumes", "docker_volumes", []), ("auto_mount_cwd", "docker_mount_cwd_to_workspace", False), @@ -48,6 +48,7 @@ _DOCKER_KWARGS = ( ("run_as_host_user", "docker_run_as_host_user", False), ("network", "docker_network", True), ("extra_args", "docker_extra_args", []), ("persist_across_processes", "docker_persist_across_processes", True), ("shared_container_key", "docker_shared_container_key", ""), ("shm_size", "docker_shm_size", "1g"), + ("snap_compat", "docker_snap_compat", False), )