diff --git a/docs/advanced/bots/copilot-bot.md b/docs/advanced/bots/copilot-bot.md index ca379fc3..b834862a 100644 --- a/docs/advanced/bots/copilot-bot.md +++ b/docs/advanced/bots/copilot-bot.md @@ -225,6 +225,7 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `wire_api` | `str` | `None` | API format: `"completions"` or `"responses"`. | | `azure_api_version` | `str` | `None` | Azure API version (for `provider_type="azure"` only). | | `token_provider` | `Callable[[], str]` | `None` | Callable returning a bearer token string. | +| `sudo_access` | `bool` | `False` | Give the agent passwordless `sudo` inside the sandbox. See [Sudo access](../sandbox-images.md#sudo-access). | ### `run()` method diff --git a/docs/advanced/sandbox-images.md b/docs/advanced/sandbox-images.md index d71f64b2..e314c6bb 100644 --- a/docs/advanced/sandbox-images.md +++ b/docs/advanced/sandbox-images.md @@ -45,3 +45,23 @@ env = LocalDockerEnvironment(port=8080, image="mcr.microsoft.com/devcontainers/b ## Tools and distros You never declare the sandbox's distribution. Tools read it from the running container's `/etc/os-release` and pick their [distro-specific commands](tools/tesseract_ocr_tool_use.md#distro-specific-commands) from it. A tool that only defines `debian` commands, such as `cscope` or `browser-use`, fails to install on the Azure Linux image with `UnsupportedDistroError`. + +## Sudo access + +By default the sandbox shell drops to an unprivileged `agent` account that has your host user's uid, and the container blocks privilege escalation (`no-new-privileges`). Some tools, such as `mock`, need root for part of their work. Pass `sudo_access=True` to any bot or to `LocalDockerEnvironment` to give the agent passwordless `sudo`: + +```python +bot = WritingBot( + model="azure-openai/your-deployment", + folder_to_mount="/path/to/repo", + image=AZURELINUX_IMAGE, + sudo_access=True, +) +``` + +- The agent's commands still run as your uid unless it explicitly uses `sudo`, so files it writes in a `READ_WRITE` mount stay owned by you. Files it writes through `sudo` are owned by root and are left that way. +- `sudo_access` applies to the environment the bot creates. If you pass your own `environment`, create it with `LocalDockerEnvironment(..., sudo_access=True)`. +- The container is started without `no-new-privileges`, so `sudo` and setuid helpers (such as `mock`'s launcher) work. If `sudo` doesn't work in the image, the environment fails to start with a `RuntimeError`. + +!!! warning + Sudo access turns the sandbox into a guardrail rather than an isolation boundary. Through `sudo` the agent has root with the container's `SYS_ADMIN` capability and AppArmor unconfined, so it can mount filesystems and reach host-wide kernel interfaces (for example `core_pattern` or a cgroup `release_agent`). A misbehaving or prompt-injected agent could therefore remount a `READ_ONLY` mount as writable, or gain root on the host. A `READ_ONLY` mount still works for a cooperative agent, and its changes are discarded with the sandbox. Enable sudo access only for trusted tasks and trusted repositories, skills, and instructions. diff --git a/src/microbots/MicroBot.py b/src/microbots/MicroBot.py index c9743d4e..1ba589f2 100644 --- a/src/microbots/MicroBot.py +++ b/src/microbots/MicroBot.py @@ -158,6 +158,11 @@ class MicroBot: image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (``DEFAULT_IMAGE``). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Applies to the auto-created environment; + a provided ``environment`` must already have sudo access. + Defaults to False. """ def __init__( @@ -170,6 +175,7 @@ def __init__( folder_to_mount: Optional[Mount] = None, token_provider: Optional[any] = None, image: Optional[str] = None, + sudo_access: bool = False, ): """ Init function for MicroBot class. @@ -205,13 +211,28 @@ def __init__( Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (``DEFAULT_IMAGE``). Cannot be combined with ``environment``. + sudo_access : bool + Give the sandbox's ``agent`` account (the host user's uid) + passwordless ``sudo``. Needed by tools such as ``mock``. + Applies to the auto-created environment; a provided + ``environment`` must already have been created with + ``sudo_access=True``. Defaults to False. Raises ------ ValueError - If both ``image`` and ``environment`` are provided. + If both ``image`` and ``environment`` are provided, or if + ``sudo_access`` is requested with an ``environment`` that does + not grant sudo access. """ + if sudo_access and environment is not None and not getattr(environment, "sudo_access", False): + raise ValueError( + "sudo_access=True requires the provided environment to be created " + "with LocalDockerEnvironment(..., sudo_access=True)." + ) + self.sudo_access = sudo_access + if image is not None and environment is not None: raise ValueError( "Pass either `image` or `environment`, not both: the image of a " @@ -510,6 +531,7 @@ def _create_environment(self, folder_to_mount: Optional[Mount]): port=free_port, folder_to_mount=folder_to_mount, image=self.image, + sudo_access=self.sudo_access, ) def _create_llm(self): diff --git a/src/microbots/bot/AgentBoss.py b/src/microbots/bot/AgentBoss.py index 73cfd66d..4623f732 100644 --- a/src/microbots/bot/AgentBoss.py +++ b/src/microbots/bot/AgentBoss.py @@ -30,6 +30,9 @@ class AgentBoss(MicroBot): image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. Workflow -------- @@ -49,6 +52,7 @@ def __init__( environment: Optional[any] = None, additional_tools: Optional[list[ToolAbstract]] = None, image: Optional[str] = None, + sudo_access: bool = False, ): """ Create the bot with the ``microbot_sub`` tool and the repository mounted read-write. @@ -67,6 +71,9 @@ def __init__( image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ if additional_tools is None: additional_tools = [] @@ -123,6 +130,7 @@ def __init__( additional_tools=additional_tools, folder_to_mount=folder_mount_info, image=image, + sudo_access=sudo_access, ) def run( diff --git a/src/microbots/bot/BrowsingBot.py b/src/microbots/bot/BrowsingBot.py index 3dfc338f..cbba23e4 100644 --- a/src/microbots/bot/BrowsingBot.py +++ b/src/microbots/bot/BrowsingBot.py @@ -29,6 +29,9 @@ class BrowsingBot(MicroBot): image : Optional[str] Shell-server image of the auto-created environment. The browser tool only supports Debian-based images (the default). Defaults to None. + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo``. + Defaults to False. """ def __init__( @@ -38,6 +41,7 @@ def __init__( additional_tools: Optional[list[ToolAbstract]] = None, token_provider: Optional[any] = None, image: Optional[str] = None, + sudo_access: bool = False, ): # validate init values before assigning bot_type = BotType.BROWSING_BOT @@ -53,6 +57,7 @@ def __init__( additional_tools=(additional_tools or []) + [BROWSER_USE_TOOL], token_provider=token_provider, image=image, + sudo_access=sudo_access, ) def run(self, task, timeout_in_seconds=200) -> BotRunResult: diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index 88d91b23..446db8c6 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -299,6 +299,15 @@ class CopilotBot: token_provider : Optional[Callable[[], str]] A callable returning a bearer token (e.g. Azure AD token provider). Requires ``base_url``. + sudo_access : bool + Give the agent passwordless ``sudo`` inside the sandbox. Needed by + tools such as ``mock``. Commands still run as the host user unless + the agent uses ``sudo``. Defaults to *False*. The sandbox then only + acts as a guardrail: a misbehaving agent could remount ``READ_ONLY`` + mounts writable or escape to the host, so use it only with trusted + tasks and repositories. + Applies to the auto-created environment; a passed-in ``environment`` + must already have been created with ``sudo_access=True``. """ def __init__( @@ -316,6 +325,7 @@ def __init__( wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, + sudo_access: bool = False, ): try: from copilot import CopilotClient, ExternalServerConfig @@ -342,6 +352,17 @@ def __init__( "e.g. pip install -U microbots[ghcp]." ) from exc + if ( + environment is not None + and sudo_access + and not getattr(environment, "sudo_access", False) + ): + raise ValueError( + "sudo_access=True requires the provided environment to be created " + "with LocalDockerEnvironment(..., sudo_access=True)." + ) + self.sudo_access = sudo_access + self.additional_tools = additional_tools or [] # ── Resolve auth: BYOK vs native GitHub Copilot ───────────── @@ -515,6 +536,7 @@ def _create_environment(self): self.environment = LocalDockerEnvironment( port=free_port, folder_to_mount=self.folder_to_mount, + sudo_access=self.sudo_access, ) def _install_copilot_cli(self): diff --git a/src/microbots/bot/LogAnalysisBot.py b/src/microbots/bot/LogAnalysisBot.py index 559acd6f..880b3fd5 100644 --- a/src/microbots/bot/LogAnalysisBot.py +++ b/src/microbots/bot/LogAnalysisBot.py @@ -46,6 +46,9 @@ class LogAnalysisBot(MicroBot): image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. Attributes ---------- @@ -80,6 +83,7 @@ def __init__( additional_tools: Optional[list[ToolAbstract]] = None, token_provider: Optional[any] = None, image: Optional[str] = None, + sudo_access: bool = False, ): """ Initialize a LogAnalysisBot instance. @@ -104,6 +108,9 @@ def __init__( image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ # validate init values before assigning bot_type = BotType.LOG_ANALYSIS_BOT @@ -132,6 +139,7 @@ def __init__( folder_to_mount=folder_mount_info, token_provider=token_provider, image=image, + sudo_access=sudo_access, ) def run( diff --git a/src/microbots/bot/ReadingBot.py b/src/microbots/bot/ReadingBot.py index f4c74bdc..8382e5e9 100644 --- a/src/microbots/bot/ReadingBot.py +++ b/src/microbots/bot/ReadingBot.py @@ -31,6 +31,9 @@ class ReadingBot(MicroBot): image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ def __init__( @@ -41,6 +44,7 @@ def __init__( additional_tools: Optional[list[ToolAbstract]] = None, token_provider: Optional[any] = None, image: Optional[str] = None, + sudo_access: bool = False, ): """ Create the bot with the repository mounted read-only. @@ -62,6 +66,9 @@ def __init__( image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ # validate init values before assigning bot_type = BotType.READING_BOT @@ -90,4 +97,5 @@ def __init__( folder_to_mount=folder_mount_info, token_provider=token_provider, image=image, + sudo_access=sudo_access, ) diff --git a/src/microbots/bot/WritingBot.py b/src/microbots/bot/WritingBot.py index 51124b2a..8c6815f5 100644 --- a/src/microbots/bot/WritingBot.py +++ b/src/microbots/bot/WritingBot.py @@ -31,6 +31,9 @@ class WritingBot(MicroBot): image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ def __init__( @@ -41,6 +44,7 @@ def __init__( additional_tools: Optional[list[ToolAbstract]] = None, token_provider: Optional[any] = None, image: Optional[str] = None, + sudo_access: bool = False, ): """ Create the bot with the repository mounted read-write. @@ -62,6 +66,9 @@ def __init__( image : Optional[str] Shell-server image of the auto-created environment, for example ``images.AZURELINUX_IMAGE``. Defaults to None (Debian image). + sudo_access : bool + Give the sandbox's agent account passwordless ``sudo`` (needed by + tools such as ``mock``). Defaults to False. """ # validate init values before assigning bot_type = BotType.WRITING_BOT @@ -95,4 +102,5 @@ def __init__( folder_to_mount=folder_mount_info, token_provider=token_provider, image=image, + sudo_access=sudo_access, ) diff --git a/src/microbots/environment/local_docker/LocalDockerEnvironment.py b/src/microbots/environment/local_docker/LocalDockerEnvironment.py index 85eee665..61534e9d 100644 --- a/src/microbots/environment/local_docker/LocalDockerEnvironment.py +++ b/src/microbots/environment/local_docker/LocalDockerEnvironment.py @@ -62,6 +62,19 @@ class LocalDockerEnvironment(Environment): its prerequisites are installed when the environment starts. Its distribution is detected from the container's ``/etc/os-release`` when needed. Defaults to ``DEFAULT_IMAGE``. + sudo_access : bool + When *True*, the ``agent`` account (which has the host user's uid) gets + passwordless ``sudo``, and the container is started without + ``no-new-privileges`` so ``sudo`` and other setuid helpers (such as + ``mock``'s) work. Commands still run as the host uid unless the agent + uses ``sudo``, so most files it writes stay owned by the host user; + files written through ``sudo`` are owned by root. Defaults to *False*. + This weakens the sandbox to a guardrail: through ``sudo`` the agent + has root with the container's ``SYS_ADMIN`` capability and AppArmor + unconfined, so it can mount filesystems (including remounting a + ``READ_ONLY`` mount writable) and reach host-wide kernel interfaces - + a misbehaving agent could gain host root. Use it only with trusted + tasks and repositories. """ def __init__( @@ -69,9 +82,21 @@ def __init__( port: int, folder_to_mount: Optional[Mount] = None, image: str = DEFAULT_IMAGE, + sudo_access: bool = False, ): self.image = image + if ( + sudo_access + and folder_to_mount + and folder_to_mount.permission == PermissionLabels.READ_ONLY + ): + logger.warning( + "⚠️ sudo_access=True with a READ_ONLY mount: with sudo the agent can remount %s " + "writable, so read-only is not enforced against a misbehaving agent.", + folder_to_mount.host_path_info.abs_path, + ) + self.sudo_access = sudo_access self.folder_to_mount = folder_to_mount self.overlay_mount = False self.container = None @@ -130,6 +155,11 @@ def start(self): # must never be reachable from the network. port_mapping = {f"{self.container_port}/tcp": ("127.0.0.1", self.port)} + # no-new-privileges blocks sudo and setuid helpers, so it is only + # dropped when the agent is explicitly granted sudo. + security_opt = ["apparmor=unconfined"] + if not self.sudo_access: + security_opt.insert(0, "no-new-privileges:true") self.container = self.client.containers.run( self.image, # Keep the stock image idle; the shell server is started over the @@ -143,7 +173,7 @@ def start(self): # SYS_ADMIN is the narrowest grant that allows the overlayfs mount; # the docker-default AppArmor profile denies mount regardless of caps. cap_add=["SYS_ADMIN"], - security_opt=["no-new-privileges:true", "apparmor=unconfined"], + security_opt=security_opt, ) logger.info( "🚀 Started container %s with image %s on host port %s", @@ -166,6 +196,31 @@ def start(self): else: self.execute("cd /") + if self.sudo_access: + self._verify_sudo_access() + + def _verify_sudo_access(self): + """Fail fast if the agent cannot use passwordless sudo, instead of + letting tools that need it fail later. + + Raises + ------ + RuntimeError + If ``sudo -n true`` fails in the bot shell; the container is stopped first. + """ + ret = self.execute("sudo -n true") + if ret.return_code != 0: + image = self.image + self.stop() + raise RuntimeError( + f"sudo_access=True but the agent cannot use sudo in image '{image}': " + f"{(ret.stderr or ret.stdout).strip() or 'unknown error'}" + ) + logger.warning( + "⚠️ Sudo access enabled: the agent can run commands as root with CAP_SYS_ADMIN. " + "The sandbox is only a guardrail now; a misbehaving agent could escape to the host." + ) + def _shell_server_environment(self) -> dict: """Environment for the shell server process inside the container.""" return { @@ -193,7 +248,8 @@ def _prepare_shell_server(self): if not self.container.put_archive(MICROBOTS_HOME, _shell_server_archive()): raise RuntimeError("Failed to copy the shell server into the sandbox") - identity = " ".join(f"{k}={v}" for k, v in self._host_identity().items()) + prepare_env = {**self._host_identity(), **({"AGENT_SUDO": "1"} if self.sudo_access else {})} + identity = " ".join(f"{k}={v}" for k, v in prepare_env.items()) ret = self.execute_privileged( f"{identity} sh {MICROBOTS_HOME}/shell_server/prepare.sh".strip() ) diff --git a/src/microbots/environment/local_docker/shell_server/prepare.sh b/src/microbots/environment/local_docker/shell_server/prepare.sh index 8cce1cdb..f7210d9b 100755 --- a/src/microbots/environment/local_docker/shell_server/prepare.sh +++ b/src/microbots/environment/local_docker/shell_server/prepare.sh @@ -10,6 +10,8 @@ MICROBOTS_HOME=/opt/microbots AGENT_USER=agent AGENT_UID="${AGENT_UID:-1001}" AGENT_GID="${AGENT_GID:-1001}" +# 1 = give the agent passwordless sudo (LocalDockerEnvironment(sudo_access=True)). +AGENT_SUDO="${AGENT_SUDO:-0}" log() { echo "[microbots-prepare] $*"; } have() { command -v "$1" >/dev/null 2>&1; } @@ -21,6 +23,7 @@ have python3 || missing="$missing python3" have mount || missing="$missing mount" have git || missing="$missing git" python3 -c "import venv, ensurepip" >/dev/null 2>&1 || missing="$missing venv" +if [ "$AGENT_SUDO" = 1 ] && ! have sudo; then missing="$missing sudo"; fi if [ -n "$missing" ]; then log "installing:$missing" @@ -31,6 +34,7 @@ if [ -n "$missing" ]; then shadow) pkgs="$pkgs passwd";; mount) pkgs="$pkgs mount";; git) pkgs="$pkgs git";; + sudo) pkgs="$pkgs sudo";; esac; done # Stale third-party sources with expired keys would break apt-get update. rm -f /etc/apt/sources.list.d/yarn.list @@ -44,6 +48,7 @@ if [ -n "$missing" ]; then shadow) pkgs="$pkgs shadow-utils";; mount) pkgs="$pkgs util-linux";; git) pkgs="$pkgs git";; + sudo) pkgs="$pkgs sudo";; esac; done "$pm" install -y $pkgs ca-certificates >/dev/null else @@ -79,6 +84,17 @@ if ! id "$AGENT_USER" >/dev/null 2>&1; then fi mkdir -p /var/log/microbots +# ---- Optional passwordless sudo for the agent. Commands still run as the +# host uid unless the agent explicitly uses sudo. +if [ "$AGENT_SUDO" = 1 ]; then + mkdir -p /etc/sudoers.d + printf '%s ALL=(ALL) NOPASSWD:ALL\n' "$AGENT_USER" > /etc/sudoers.d/microbots-agent + chmod 0440 /etc/sudoers.d/microbots-agent + # Some images (e.g. Azure Linux) ship /etc/shadow with mode 0000, which + # sudo's PAM account check cannot read inside a container. + if [ -f /etc/shadow ]; then chmod u+r /etc/shadow; fi +fi + # Bind-mounted repos are owned by the host user, not whoever runs inside the # container, which trips git's "dubious ownership" safety check. if have git && ! git config --system --get-all safe.directory 2>/dev/null | grep -qx '\*'; then diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 9d7d2b62..d5a9f3c9 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -1639,6 +1639,67 @@ def test_run_with_old_sdk_layout_types(self): assert kwargs["on_permission_request"] is types_handler.approve_all +# --------------------------------------------------------------------------- +# Unit tests — sudo_access +# --------------------------------------------------------------------------- + +@contextlib.contextmanager +def _root_bot_dependencies(mock_environment, client): + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment) as lde, + patch("microbots.bot.CopilotBot.get_free_port", return_value=9000), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=client), + patch("copilot.ExternalServerConfig", return_value=MagicMock()), + ): + from microbots.bot.CopilotBot import CopilotBot + yield CopilotBot, lde + + +@pytest.mark.unit +class TestCopilotBotSudoAccess: + """sudo_access is forwarded to the auto-created environment.""" + + @pytest.mark.parametrize("sudo_access", [False, True]) + def test_forwarded_to_auto_created_environment( + self, mock_environment, mock_copilot_client, sudo_access + ): + with _root_bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + bot = CopilotBot(model="gpt-4.1", github_token="ghp_test", sudo_access=sudo_access) + bot.stop() + + assert lde.call_args.kwargs["sudo_access"] is sudo_access + assert bot.sudo_access is sudo_access + + def test_rejects_unprivileged_provided_environment(self, mock_environment, mock_copilot_client): + mock_environment.sudo_access = False + with _root_bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + with pytest.raises(ValueError, match="sudo_access=True"): + CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + sudo_access=True, + ) + lde.assert_not_called() + + def test_accepts_root_provided_environment(self, mock_environment, mock_copilot_client): + mock_environment.sudo_access = True + with _root_bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + sudo_access=True, + ) + bot.stop() + + assert bot.sudo_access is True + lde.assert_not_called() + + # --------------------------------------------------------------------------- # Integration tests — require real Docker + copilot-SDK + auth # --------------------------------------------------------------------------- diff --git a/test/bot/test_microbot.py b/test/bot/test_microbot.py index 60411ff1..a1edf9fc 100644 --- a/test/bot/test_microbot.py +++ b/test/bot/test_microbot.py @@ -1091,7 +1091,7 @@ def test_forwarded_to_auto_created_environment(self, image, expected): bot = MicroBot(model="azure-openai/test-deploy", image=image) assert bot.image == expected - lde.assert_called_once_with(port=4242, folder_to_mount=None, image=expected) + lde.assert_called_once_with(port=4242, folder_to_mount=None, image=expected, sudo_access=False) def test_rejects_image_with_provided_environment(self): with patch.object(MicroBot, "_create_llm"), \ @@ -1120,3 +1120,57 @@ def test_subclasses_forward_image(self, bot_name, kwargs, image): with patch.object(MicroBot, "__init__", return_value=None) as base_init: getattr(bots, bot_name)(model="azure-openai/test-deploy", image=image, **kwargs) assert base_init.call_args.kwargs["image"] == image + + +@pytest.mark.unit +class TestMicroBotSudoAccess: + """sudo_access is opt-in and forwarded to the auto-created environment.""" + + @pytest.mark.parametrize("sudo_access", [False, True]) + def test_forwarded_to_auto_created_environment(self, sudo_access): + with patch("microbots.MicroBot.LocalDockerEnvironment") as lde, \ + patch("microbots.MicroBot.get_free_port", return_value=4242), \ + patch.object(MicroBot, "_create_llm"): + bot = MicroBot(model="azure-openai/test-deploy", sudo_access=sudo_access) + + assert bot.sudo_access is sudo_access + assert lde.call_args.kwargs["sudo_access"] is sudo_access + assert bot.environment is lde.return_value + + def test_rejects_unprivileged_provided_environment(self): + env = MagicMock(sudo_access=False) + with patch.object(MicroBot, "_create_llm"), \ + pytest.raises(ValueError, match="sudo_access=True requires the provided environment"): + MicroBot(model="azure-openai/test-deploy", environment=env, sudo_access=True) + + def test_accepts_root_provided_environment(self): + env = MagicMock(sudo_access=True) + with patch.object(MicroBot, "_create_environment") as create_env, \ + patch.object(MicroBot, "_create_llm"): + bot = MicroBot(model="azure-openai/test-deploy", environment=env, sudo_access=True) + assert bot.environment is env + create_env.assert_not_called() + + @pytest.mark.parametrize("bot_name, kwargs", [ + ("ReadingBot", {"folder_to_mount": "/tmp/repo"}), + ("WritingBot", {"folder_to_mount": "/tmp/repo"}), + ("LogAnalysisBot", {"folder_to_mount": "/tmp/repo"}), + ("BrowsingBot", {}), + ("AgentBoss", {"folder_to_mount": "/tmp/repo"}), + ]) + @pytest.mark.parametrize("sudo_access", [False, True]) + def test_subclasses_forward_sudo_access(self, bot_name, kwargs, sudo_access): + import microbots.bot as bots + + with patch.object(MicroBot, "__init__", return_value=None) as base_init: + getattr(bots, bot_name)(model="azure-openai/test-deploy", sudo_access=sudo_access, **kwargs) + assert base_init.call_args.kwargs["sudo_access"] is sudo_access + + @pytest.mark.parametrize("bot_name", ["ReadingBot", "WritingBot", "LogAnalysisBot", "BrowsingBot", "AgentBoss"]) + def test_subclasses_default_to_unprivileged(self, bot_name): + import microbots.bot as bots + + kwargs = {} if bot_name == "BrowsingBot" else {"folder_to_mount": "/tmp/repo"} + with patch.object(MicroBot, "__init__", return_value=None) as base_init: + getattr(bots, bot_name)(model="azure-openai/test-deploy", **kwargs) + assert base_init.call_args.kwargs["sudo_access"] is False diff --git a/test/environment/local_docker/test_images.py b/test/environment/local_docker/test_images.py index 1921916b..38b833d2 100644 --- a/test/environment/local_docker/test_images.py +++ b/test/environment/local_docker/test_images.py @@ -43,3 +43,11 @@ def test_prepare_script_installs_server_dependencies_into_root_venv(): assert "fastapi uvicorn pydantic" in text # The devcontainer image's passwordless-sudo account must not survive. assert "remove_user vscode" in text + + +def test_prepare_script_grants_sudo_only_when_requested(): + text = PREPARE.read_text() + assert 'AGENT_SUDO="${AGENT_SUDO:-0}"' in text + assert 'if [ "$AGENT_SUDO" = 1 ]; then' in text + assert "ALL=(ALL) NOPASSWD:ALL" in text + assert "chmod 0440 /etc/sudoers.d/microbots-agent" in text diff --git a/test/environment/local_docker/test_local_docker_environment.py b/test/environment/local_docker/test_local_docker_environment.py index a4aa293c..3197ffe7 100644 --- a/test/environment/local_docker/test_local_docker_environment.py +++ b/test/environment/local_docker/test_local_docker_environment.py @@ -583,6 +583,7 @@ def test_host_identity(self, monkeypatch, uid, expected): def test_start_restricts_container_privileges_and_published_port(self, env): env.image = "test_image" + env.sudo_access = False env.working_dir = "/tmp/test_workdir" env.folder_to_mount = None env.port = 12345 @@ -612,6 +613,7 @@ def test_start_restricts_container_privileges_and_published_port(self, env): @pytest.mark.parametrize("failing", ["_prepare_shell_server", "_launch_shell_server"]) def test_start_stops_container_when_shell_server_setup_fails(self, env, failing): env.image = "test_image" + env.sudo_access = False env.working_dir = "/tmp/test_workdir" env.folder_to_mount = None env.port = 12345 @@ -765,6 +767,7 @@ def env(self): env = LocalDockerEnvironment.__new__(LocalDockerEnvironment) env.deleted = True env.image = "mcr.example/stock:1" + env.sudo_access = False env.port = 12345 env.container_port = 8080 env.container = Mock() @@ -790,11 +793,14 @@ def test_archive_contains_root_owned_shell_server(self): assert members["shell_server/prepare.sh"].mode == 0o755 assert members["shell_server/dockerShell.py"].mode == 0o644 - @pytest.mark.parametrize("identity, prefix", [ - ({}, ""), - ({"AGENT_UID": "2001", "AGENT_GID": "2002"}, "AGENT_UID=2001 AGENT_GID=2002 "), + @pytest.mark.parametrize("identity, sudo_access, prefix", [ + ({}, False, ""), + ({"AGENT_UID": "2001", "AGENT_GID": "2002"}, False, "AGENT_UID=2001 AGENT_GID=2002 "), + ({}, True, "AGENT_SUDO=1 "), + ({"AGENT_UID": "2001", "AGENT_GID": "2002"}, True, "AGENT_UID=2001 AGENT_GID=2002 AGENT_SUDO=1 "), ]) - def test_prepare_copies_server_and_runs_prepare_script_as_root(self, env, identity, prefix): + def test_prepare_copies_server_and_runs_prepare_script_as_root(self, env, identity, sudo_access, prefix): + env.sudo_access = sudo_access with patch.object(env, "_host_identity", return_value=identity): env._prepare_shell_server() @@ -846,3 +852,117 @@ def test_launch_times_out_with_server_log(self, env): patch("microbots.environment.local_docker.LocalDockerEnvironment.time.monotonic", side_effect=lambda: next(clock)): with pytest.raises(RuntimeError, match="did not start within 120s(.|\n)*Traceback: boom"): env._launch_shell_server() + + +@pytest.mark.unit +class TestSudoAccess: + """Unit tests for the opt-in passwordless sudo mode.""" + + @pytest.fixture + def env(self): + env = LocalDockerEnvironment.__new__(LocalDockerEnvironment) + env.deleted = True + env.image = "test_image" + env.sudo_access = True + env.working_dir = "/tmp/test_workdir" + env.folder_to_mount = None + env.port = 12345 + env.container_port = 8080 + env.client = Mock() + env.client.containers.run.return_value.id = "test_container" + env.container = Mock() + env.overlay_mount = False + env.execute = Mock(return_value=CmdReturn("", "", 0)) + env.execute_privileged = Mock(return_value=CmdReturn("", "", 0)) + return env + + def test_start_allows_privilege_escalation_and_verifies_sudo(self, env): + with patch.object(env, "_verify_sudo_access") as verify, \ + patch.object(env, "_prepare_shell_server"), \ + patch.object(env, "_launch_shell_server"): + env.start() + + # sudo and setuid helpers need no-new-privileges off; everything else is unchanged. + kwargs = env.client.containers.run.call_args.kwargs + assert kwargs["security_opt"] == ["apparmor=unconfined"] + assert kwargs["cap_add"] == ["SYS_ADMIN"] + verify.assert_called_once_with() + + def test_start_without_sudo_keeps_no_new_privileges_and_skips_check(self, env): + env.sudo_access = False + with patch.object(env, "_verify_sudo_access") as verify, \ + patch.object(env, "_prepare_shell_server"), \ + patch.object(env, "_launch_shell_server"): + env.start() + + assert env.client.containers.run.call_args.kwargs["security_opt"] == [ + "no-new-privileges:true", "apparmor=unconfined" + ] + verify.assert_not_called() + + def test_shell_server_never_gets_a_root_flag(self, env): + # The shell always drops to the agent account; sudo is configured by prepare.sh. + with patch.object(env, "_host_identity", return_value={}): + assert env._shell_server_environment() == {"BOT_PORT": "8080", "BOT_WORKDIR": DOCKER_WORKING_DIR} + + def test_init_defaults_to_no_sudo(self): + with patch.object(LocalDockerEnvironment, "_create_working_dir"), \ + patch.object(LocalDockerEnvironment, "start"), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.docker"): + env = LocalDockerEnvironment(port=1234) + env.deleted = True + assert env.sudo_access is False + + def test_init_warns_but_allows_read_only_mount(self, tmp_path, caplog): + mount = Mount(str(tmp_path), f"{DOCKER_WORKING_DIR}/repo", "READ_ONLY") + with patch.object(LocalDockerEnvironment, "_create_working_dir"), \ + patch.object(LocalDockerEnvironment, "start") as start, \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.docker"), \ + caplog.at_level(logging.WARNING): + env = LocalDockerEnvironment(port=1234, folder_to_mount=mount, sudo_access=True) + env.deleted = True + start.assert_called_once_with() + assert env.sudo_access is True + assert caplog.text.count("sudo_access=True with a READ_ONLY mount") == 1 + assert str(tmp_path) in caplog.text + + @pytest.mark.parametrize("sudo_access, permission", [(False, "READ_ONLY"), (True, "READ_WRITE")]) + def test_init_no_read_only_warning_otherwise(self, tmp_path, caplog, sudo_access, permission): + mount = Mount(str(tmp_path), f"{DOCKER_WORKING_DIR}/repo", permission) + with patch.object(LocalDockerEnvironment, "_create_working_dir"), \ + patch.object(LocalDockerEnvironment, "start"), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.docker"), \ + caplog.at_level(logging.WARNING): + env = LocalDockerEnvironment(port=1234, folder_to_mount=mount, sudo_access=sudo_access) + env.deleted = True + assert "READ_ONLY mount" not in caplog.text + + def test_verify_sudo_access_accepts_passwordless_sudo(self, env, caplog): + with caplog.at_level(logging.WARNING): + env._verify_sudo_access() + + env.execute.assert_called_once_with("sudo -n true") + assert "Sudo access enabled" in caplog.text + + @pytest.mark.parametrize("result, shown", [ + (CmdReturn("", "sudo: a password is required", 1), "sudo: a password is required"), + (CmdReturn("", "", 127), "unknown error"), + ]) + def test_verify_sudo_access_stops_and_raises_when_sudo_fails(self, env, result, shown): + env.execute.return_value = result + env.stop = Mock() + + with pytest.raises(RuntimeError, match=f"cannot use sudo in image 'test_image': {shown}"): + env._verify_sudo_access() + + env.stop.assert_called_once_with() + + def test_stop_does_not_change_file_ownership(self, env, tmp_path): + env.working_dir = str(tmp_path / "missing") + container = env.container + + env.stop() + + assert not any("chown" in str(c) for c in env.execute_privileged.call_args_list) + container.stop.assert_called_once_with() + container.remove.assert_called_once_with() diff --git a/test/environment/local_docker/test_sudo_access.py b/test/environment/local_docker/test_sudo_access.py new file mode 100644 index 00000000..b87105cb --- /dev/null +++ b/test/environment/local_docker/test_sudo_access.py @@ -0,0 +1,55 @@ +"""Integration tests for LocalDockerEnvironment(sudo_access=...) on the stock images. + +Requires Docker and network access (the images are prepared on the fly). +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src"))) + +from microbots.constants import DOCKER_WORKING_DIR, PermissionLabels +from microbots.environment.local_docker.LocalDockerEnvironment import LocalDockerEnvironment +from microbots.environment.local_docker.images import AZURELINUX_IMAGE, DEBIAN_IMAGE +from microbots.extras.mount import Mount +from microbots.utils.network import get_free_port + +IMAGES = pytest.mark.parametrize("image", [DEBIAN_IMAGE, AZURELINUX_IMAGE], ids=["debian", "azurelinux"]) + + +@pytest.mark.integration +@pytest.mark.docker +class TestSudoAccess: + + @IMAGES + def test_agent_keeps_host_uid_and_gets_passwordless_sudo(self, tmp_path, image): + env = LocalDockerEnvironment( + port=get_free_port(), + image=image, + sudo_access=True, + folder_to_mount=Mount(str(tmp_path), f"{DOCKER_WORKING_DIR}/repo", PermissionLabels.READ_WRITE), + ) + try: + ret = env.execute("id -un && id -u && sudo -n id -u && echo written > new.txt") + assert ret.return_code == 0, ret.stderr + name, uid, sudo_uid = ret.stdout.split() + assert name == "agent" + assert sudo_uid == "0" + if hasattr(os, "getuid") and os.getuid() != 0: + assert uid == str(os.getuid()) + finally: + env.stop() + # Without sudo, files are written as the host user; no ownership fix-up is needed. + if hasattr(os, "getuid"): + assert (tmp_path / "new.txt").stat().st_uid == os.getuid() + + @IMAGES + def test_no_sudo_by_default(self, image): + env = LocalDockerEnvironment(port=get_free_port(), image=image) + try: + ret = env.execute("sudo -n true") + assert ret.return_code != 0 + finally: + env.stop()