From 3f59bd1443b971474968dfb75f02fb13e635fc8c Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Sun, 12 Apr 2026 15:09:22 +0800 Subject: [PATCH] fix(shell): reject LLM-supplied working_dir outside workspace (#2826) --- nanobot/agent/tools/shell.py | 15 +++++++ tests/tools/test_exec_security.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 6af9629aa..729afa60b 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -101,6 +101,21 @@ class ExecTool(Tool): timeout: int | None = None, **kwargs: Any, ) -> str: cwd = working_dir or self.working_dir or os.getcwd() + + # Prevent an LLM-supplied working_dir from escaping the configured + # workspace when restrict_to_workspace is enabled (#2826). Without + # this, a caller can pass working_dir="/etc" and then all absolute + # paths under /etc would pass the _guard_command check that anchors + # on cwd. + if self.restrict_to_workspace and self.working_dir: + try: + requested = Path(cwd).expanduser().resolve() + workspace_root = Path(self.working_dir).expanduser().resolve() + except Exception: + return "Error: working_dir could not be resolved" + if requested != workspace_root and workspace_root not in requested.parents: + return "Error: working_dir is outside the configured workspace" + guard_error = self._guard_command(command, cwd) if guard_error: return guard_error diff --git a/tests/tools/test_exec_security.py b/tests/tools/test_exec_security.py index bb8fc21ec..9f001aaff 100644 --- a/tests/tools/test_exec_security.py +++ b/tests/tools/test_exec_security.py @@ -113,3 +113,71 @@ def test_exec_allows_reads_of_history_jsonl(command): tool = ExecTool() result = tool._guard_command(command, "/tmp") assert result is None + + +# --- #2826: working_dir must not escape the configured workspace --------- + + +@pytest.mark.asyncio +async def test_exec_blocks_working_dir_outside_workspace(tmp_path): + """An LLM-supplied working_dir outside the workspace must be rejected.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True) + result = await tool.execute(command="rm calendar.ics", working_dir="/etc") + assert "outside the configured workspace" in result + + +@pytest.mark.asyncio +async def test_exec_blocks_absolute_rm_via_hijacked_working_dir(tmp_path): + """Regression for #2826: `rm /abs/path` via working_dir hijack.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + victim_dir = tmp_path / "outside" + victim_dir.mkdir() + victim = victim_dir / "file.ics" + victim.write_text("data") + + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True) + result = await tool.execute( + command=f"rm {victim}", + working_dir=str(victim_dir), + ) + assert "outside the configured workspace" in result + assert victim.exists(), "victim file must not have been deleted" + + +@pytest.mark.asyncio +async def test_exec_allows_working_dir_within_workspace(tmp_path): + """A working_dir that is a subdirectory of the workspace is fine.""" + workspace = tmp_path / "workspace" + subdir = workspace / "project" + subdir.mkdir(parents=True) + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5) + result = await tool.execute(command="echo ok", working_dir=str(subdir)) + assert "ok" in result + assert "outside the configured workspace" not in result + + +@pytest.mark.asyncio +async def test_exec_allows_working_dir_equal_to_workspace(tmp_path): + """Passing working_dir equal to the workspace root must be allowed.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5) + result = await tool.execute(command="echo ok", working_dir=str(workspace)) + assert "ok" in result + assert "outside the configured workspace" not in result + + +@pytest.mark.asyncio +async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path): + """Without restrict_to_workspace, the LLM may still choose any working_dir.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + other = tmp_path / "other" + other.mkdir() + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=False, timeout=5) + result = await tool.execute(command="echo ok", working_dir=str(other)) + assert "ok" in result + assert "outside the configured workspace" not in result