From 688c2ccaf0d59af2136686d98eec2da13bd8238d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= Date: Thu, 9 Jul 2026 13:54:54 +0200 Subject: [PATCH 1/3] Add path guard to CloneRepositoryTool Reject clone_path values outside the shared GIT_REPO_BASEPATH volume (/git-repos) with a clear ToolError, preventing agents from cloning into container-local paths like /tmp that are invisible to other containers. The guard resolves both sides (Path.resolve) to block ".." traversal attacks, and runs before clean_stale_repositories to skip unnecessary I/O on invalid paths. Also updates the clone_path field description to mention the /git-repos constraint. Assisted-by: Claude (Cursor) --- ymir/tools/privileged/gitlab.py | 11 ++++- .../privileged/tests/unit/test_gitlab.py | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index 872c5bd51..f978743b5 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -432,7 +432,9 @@ async def _run( class CloneRepositoryToolInput(BaseModel): repository: str = Field(description="Repository to clone") branch: str | None = Field(default=None, description="Branch to clone. If omitted, all refs are fetched.") - clone_path: AbsolutePath = Field(description="Absolute path where to clone the repository") + clone_path: AbsolutePath = Field( + description="Absolute path under the shared /git-repos volume where to clone the repository" + ) class CloneRepositoryTool(Tool[CloneRepositoryToolInput, ToolRunOptions, StringToolOutput]): @@ -460,6 +462,13 @@ async def _run( repository = tool_input.repository branch = tool_input.branch clone_path = tool_input.clone_path + + basepath = Path(os.getenv("GIT_REPO_BASEPATH", "/git-repos")).resolve() + resolved = clone_path.resolve() + if not resolved.is_relative_to(basepath): + raise ToolError(f"clone_path must be under {basepath} (the shared volume). Got: {clone_path}") + clone_path = resolved + await clean_stale_repositories() auth_args = _get_git_auth_args(repository) diff --git a/ymir/tools/privileged/tests/unit/test_gitlab.py b/ymir/tools/privileged/tests/unit/test_gitlab.py index 8d823e801..60f6606a4 100644 --- a/ymir/tools/privileged/tests/unit/test_gitlab.py +++ b/ymir/tools/privileged/tests/unit/test_gitlab.py @@ -4,6 +4,7 @@ import gitlab import pytest +from beeai_framework.tools import ToolError from flexmock import flexmock from ogr.abstract import PRStatus from ogr.exceptions import GitlabAPIException @@ -229,6 +230,51 @@ async def wait(): assert result.startswith("Successfully") +@pytest.mark.asyncio +@pytest.mark.parametrize( + "bad_path", + [ + Path("/tmp/bash"), + Path("/var/lib/bash"), + ], + ids=["outside-base", "unrelated-absolute"], +) +async def test_clone_repository_rejects_path_outside_basepath(mock_git_repo_basepath, bad_path): + with pytest.raises(ToolError, match="must be under"): + await CloneRepositoryTool().run( + input={"repository": "https://gitlab.com/redhat/rhel/rpms/bash", "clone_path": bad_path} + ) + + +@pytest.mark.asyncio +async def test_clone_repository_rejects_path_traversal(mock_git_repo_basepath): + traversal_path = mock_git_repo_basepath / ".." / "tmp" / "bash" + with pytest.raises(ToolError, match="must be under"): + await CloneRepositoryTool().run( + input={"repository": "https://gitlab.com/redhat/rhel/rpms/bash", "clone_path": traversal_path} + ) + + +@pytest.mark.asyncio +async def test_clone_repository_accepts_path_inside_basepath(mock_git_repo_basepath): + valid_path = mock_git_repo_basepath / "RHEL-12345" / "bash" + + async def create_subprocess_exec(cmd, *args, **kwargs): + async def wait(): + return 0 + + return flexmock(wait=wait) + + flexmock(asyncio).should_receive("create_subprocess_exec").replace_with(create_subprocess_exec) + + result = ( + await CloneRepositoryTool().run( + input={"repository": "https://gitlab.com/redhat/rhel/rpms/bash", "clone_path": valid_path} + ) + ).result + assert result.startswith("Successfully") + + @pytest.mark.asyncio async def test_push_to_remote_repository(): repository = "https://gitlab.com/ai-bot/bash.git" From b45323332c2bc030b3a666bf03190aed09b1ffeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= Date: Thu, 9 Jul 2026 13:55:06 +0200 Subject: [PATCH 2/3] Update triage prompt to specify clone_path under /git-repos Instruct the LLM to pass clone_path=/git-repos// when calling clone_repository, so cloned repos land on the shared volume visible to both the agent and MCP gateway containers. Previously the prompt omitted clone_path, letting the model pick arbitrary locations like /tmp that only exist inside the gateway. Assisted-by: Claude (Cursor) --- ymir/agents/prompts/triage/prompt.j2 | 6 ++++-- ymir/agents/tests/unit/test_jinja2_templates.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ymir/agents/prompts/triage/prompt.j2 b/ymir/agents/prompts/triage/prompt.j2 index 8ec4b5ea8..4240437b9 100644 --- a/ymir/agents/prompts/triage/prompt.j2 +++ b/ymir/agents/prompts/triage/prompt.j2 @@ -39,9 +39,11 @@ Goal: Analyze the given issue to determine the correct course of action. * Determine the name of the package from the issue details (usually component name) {% if needs_internal_fix and internal_target_branch %} * Use the clone_repository tool to clone `https://gitlab.com/redhat/rhel/rpms/` - with branch `{{ internal_target_branch }}`. + with branch `{{ internal_target_branch }}` and + clone_path `/git-repos/{{ issue }}/`. If the clone fails (branch or repo not found), fall back to cloning - `https://gitlab.com/redhat/centos-stream/rpms/` using the same tool. + `https://gitlab.com/redhat/centos-stream/rpms/` using the same tool + (keep the same clone_path). {% else %} * Confirm the package repository exists by running `GIT_TERMINAL_PROMPT=0 git ls-remote https://gitlab.com/redhat/centos-stream/rpms/` diff --git a/ymir/agents/tests/unit/test_jinja2_templates.py b/ymir/agents/tests/unit/test_jinja2_templates.py index dec230a0d..7124e2ff4 100644 --- a/ymir/agents/tests/unit/test_jinja2_templates.py +++ b/ymir/agents/tests/unit/test_jinja2_templates.py @@ -467,6 +467,8 @@ def test_renders_needs_internal_fix(self): assert "redhat/rhel/rpms" in result assert "rhel-10.2" in result assert "centos-stream" in result + assert "clone_path" in result + assert "/git-repos/RHEL-189361/" in result def test_renders_needs_internal_fix_without_branch_falls_back(self): result = render_template( From 6d080a0b74f933be644e0298d5ea5b65d1a3b822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= Date: Thu, 9 Jul 2026 14:36:01 +0200 Subject: [PATCH 3/3] Reject cloning directly into basepath root Disallow clone_path == GIT_REPO_BASEPATH itself (e.g. /git-repos) to prevent creating .git in the shared volume root. Assisted-by: Claude (Cursor) --- ymir/tools/privileged/gitlab.py | 2 +- ymir/tools/privileged/tests/unit/test_gitlab.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index f978743b5..778c176a7 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -465,7 +465,7 @@ async def _run( basepath = Path(os.getenv("GIT_REPO_BASEPATH", "/git-repos")).resolve() resolved = clone_path.resolve() - if not resolved.is_relative_to(basepath): + if resolved == basepath or not resolved.is_relative_to(basepath): raise ToolError(f"clone_path must be under {basepath} (the shared volume). Got: {clone_path}") clone_path = resolved diff --git a/ymir/tools/privileged/tests/unit/test_gitlab.py b/ymir/tools/privileged/tests/unit/test_gitlab.py index 60f6606a4..04b6f12ea 100644 --- a/ymir/tools/privileged/tests/unit/test_gitlab.py +++ b/ymir/tools/privileged/tests/unit/test_gitlab.py @@ -255,6 +255,17 @@ async def test_clone_repository_rejects_path_traversal(mock_git_repo_basepath): ) +@pytest.mark.asyncio +async def test_clone_repository_rejects_basepath_root(mock_git_repo_basepath): + with pytest.raises(ToolError, match="must be under"): + await CloneRepositoryTool().run( + input={ + "repository": "https://gitlab.com/redhat/rhel/rpms/bash", + "clone_path": mock_git_repo_basepath, + } + ) + + @pytest.mark.asyncio async def test_clone_repository_accepts_path_inside_basepath(mock_git_repo_basepath): valid_path = mock_git_repo_basepath / "RHEL-12345" / "bash"