Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

@ -12,6 +12,7 @@ from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import compute_connector_counts
from vibe.core.tools.connectors.connector_registry import (
ConnectorAuthAction,
ConnectorRegistry,
@ -882,3 +883,84 @@ class TestAuthActionablediscovery:
assert "linear" in registry.get_connector_names()
assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH
assert registry.get_connector_id("linear") == "c-1"
# ---------------------------------------------------------------------------
# Banner connector counts
# ---------------------------------------------------------------------------
class TestComputeConnectorCounts:
def test_no_registry(self) -> None:
assert compute_connector_counts(build_test_vibe_config(), None) == (0, 0)
def test_empty_registry(self) -> None:
registry = FakeConnectorRegistry()
assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 0)
def test_no_config_entry_is_disabled_by_default(self) -> None:
# Mirrors ToolManager: connectors without an explicit ConnectorConfig
# entry are disabled (their tools are not registered).
registry = FakeConnectorRegistry({
"alpha": [RemoteTool(name="search")],
"beta": [RemoteTool(name="list")],
})
assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 2)
def test_explicitly_enabled_and_connected(self) -> None:
registry = FakeConnectorRegistry({
"alpha": [RemoteTool(name="search")],
"beta": [RemoteTool(name="list")],
})
config = build_test_vibe_config(
connectors=[
ConnectorConfig(name="alpha", disabled=False),
ConnectorConfig(name="beta", disabled=False),
]
)
assert compute_connector_counts(config, registry) == (2, 2)
def test_auth_pending_not_counted(self) -> None:
# Empty tool list → FakeConnectorRegistry marks as not connected.
registry = FakeConnectorRegistry(
{"alpha": [RemoteTool(name="search")], "needs_auth": []},
auth_actions={"needs_auth": ConnectorAuthAction.OAUTH},
)
config = build_test_vibe_config(
connectors=[
ConnectorConfig(name="alpha", disabled=False),
ConnectorConfig(name="needs_auth", disabled=False),
]
)
assert compute_connector_counts(config, registry) == (1, 2)
def test_disabled_not_counted(self) -> None:
registry = FakeConnectorRegistry({
"alpha": [RemoteTool(name="search")],
"beta": [RemoteTool(name="list")],
})
config = build_test_vibe_config(
connectors=[
ConnectorConfig(name="alpha", disabled=False),
ConnectorConfig(name="beta", disabled=True),
]
)
assert compute_connector_counts(config, registry) == (1, 2)
def test_disabled_and_pending_combined(self) -> None:
registry = FakeConnectorRegistry(
{
"alpha": [RemoteTool(name="search")],
"beta": [RemoteTool(name="list")],
"needs_auth": [],
},
auth_actions={"needs_auth": ConnectorAuthAction.OAUTH},
)
config = build_test_vibe_config(
connectors=[
ConnectorConfig(name="alpha", disabled=False),
ConnectorConfig(name="beta", disabled=True),
ConnectorConfig(name="needs_auth", disabled=False),
]
)
assert compute_connector_counts(config, registry) == (1, 3)

View file

@ -11,18 +11,9 @@ from vibe.core.tools.builtins.bash import (
BashToolConfig,
_collect_outside_dirs,
)
from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepToolConfig
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileArgs,
ReadFileState,
ReadFileToolConfig,
)
from vibe.core.tools.builtins.search_replace import (
SearchReplace,
SearchReplaceArgs,
SearchReplaceConfig,
)
from vibe.core.tools.builtins.read import Read, ReadArgs, ReadConfig, ReadState
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
from vibe.core.tools.builtins.write_file import (
WriteFile,
@ -262,24 +253,24 @@ class TestBashGranularPermissions:
assert len(outside) >= 1
class TestReadFileGranularPermissions:
class TestReadGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def _read_file(self, **kwargs):
config = ReadFileToolConfig(**kwargs)
return ReadFile(config_getter=lambda: config, state=ReadFileState())
def _read(self, **kwargs):
config = ReadConfig(**kwargs)
return Read(config_getter=lambda: config, state=ReadState())
def test_in_workdir_normal_file_returns_none(self):
(self.workdir / "test.py").touch()
tool = self._read_file()
assert tool.resolve_permission(ReadFileArgs(path="test.py")) is None
tool = self._read()
assert tool.resolve_permission(ReadArgs(file_path="test.py")) is None
def test_outside_workdir_returns_permission_context(self):
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path="/tmp/file.txt"))
tool = self._read()
result = tool.resolve_permission(ReadArgs(file_path="/tmp/file.txt"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
outside = [
@ -291,8 +282,8 @@ class TestReadFileGranularPermissions:
def test_sensitive_env_file_returns_permission_context(self):
(self.workdir / ".env").touch()
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path=".env"))
tool = self._read()
result = tool.resolve_permission(ReadArgs(file_path=".env"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
sensitive = [
@ -305,8 +296,8 @@ class TestReadFileGranularPermissions:
def test_sensitive_env_local_file(self):
(self.workdir / ".env.local").touch()
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path=".env.local"))
tool = self._read()
result = tool.resolve_permission(ReadArgs(file_path=".env.local"))
assert isinstance(result, PermissionContext)
sensitive = [
rp
@ -316,31 +307,31 @@ class TestReadFileGranularPermissions:
assert len(sensitive) == 1
def test_sensitive_outside_both_permissions(self):
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path="/tmp/.env"))
tool = self._read()
result = tool.resolve_permission(ReadArgs(file_path="/tmp/.env"))
assert isinstance(result, PermissionContext)
scopes = {rp.scope for rp in result.required_permissions}
assert PermissionScope.FILE_PATTERN in scopes
assert PermissionScope.OUTSIDE_DIRECTORY in scopes
def test_denylisted_returns_never(self):
tool = self._read_file(denylist=["*/secret*"])
result = tool.resolve_permission(ReadFileArgs(path="secret.key"))
tool = self._read(denylist=["*/secret*"])
result = tool.resolve_permission(ReadArgs(file_path="secret.key"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_allowlisted_returns_always(self):
tool = self._read_file(allowlist=["*/README*"])
tool = self._read(allowlist=["*/README*"])
result = tool.resolve_permission(
ReadFileArgs(path=str(self.workdir / "README.md"))
ReadArgs(file_path=str(self.workdir / "README.md"))
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_custom_sensitive_patterns(self):
(self.workdir / "credentials.json").touch()
tool = self._read_file(sensitive_patterns=["*/credentials*"])
result = tool.resolve_permission(ReadFileArgs(path="credentials.json"))
tool = self._read(sensitive_patterns=["*/credentials*"])
result = tool.resolve_permission(ReadArgs(file_path="credentials.json"))
assert isinstance(result, PermissionContext)
@ -371,23 +362,21 @@ class TestWriteFileGranularPermissions:
def test_sensitive_env_file_asks(self):
(self.workdir / ".env").touch()
tool = self._write_file()
result = tool.resolve_permission(
WriteFileArgs(path=".env", content="x", overwrite=True)
)
result = tool.resolve_permission(WriteFileArgs(path=".env", content="x"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
class TestSearchReplaceGranularPermissions:
class TestEditGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
def test_outside_workdir_returns_permission_context(self):
config = SearchReplaceConfig()
tool = SearchReplace(config_getter=lambda: config, state=BaseToolState())
config = EditConfig()
tool = Edit(config_getter=lambda: config, state=BaseToolState())
result = tool.resolve_permission(
SearchReplaceArgs(file_path="/tmp/file.py", content="x")
EditArgs(file_path="/tmp/file.py", old_string="a", new_string="b")
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
@ -546,10 +535,10 @@ class TestApprovalFlowSimulation:
uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)]
assert len(uncovered) == 1
def test_read_file_sensitive_approved_covers_subsequent(self):
def test_read_sensitive_approved_covers_subsequent(self):
rules = [
ApprovedRule(
tool_name="read_file",
tool_name="read",
scope=PermissionScope.FILE_PATTERN,
session_pattern="*",
)
@ -558,9 +547,9 @@ class TestApprovalFlowSimulation:
scope=PermissionScope.FILE_PATTERN,
invocation_pattern=".env.production",
session_pattern="*",
label="reading sensitive files (read_file)",
label="reading sensitive files (read)",
)
assert self._is_covered("read_file", rp, rules)
assert self._is_covered("read", rp, rules)
def test_different_tool_rule_doesnt_cover(self):
rules = [

View file

@ -6,7 +6,7 @@ import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.manager import NoSuchToolError, ToolManager
@pytest.fixture
@ -75,17 +75,16 @@ def test_partial_override_preserves_tool_defaults():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"read_file": {"max_read_bytes": 32000}},
tools={"read": {"sensitive_patterns": ["**/*.key"]}},
)
manager = ToolManager(lambda: vibe_config)
config = manager.get_tool_config("read_file")
config = manager.get_tool_config("read")
assert (
config.permission == ToolPermission.ALWAYS
) # ReadFileToolConfig default, not BaseToolConfig.ASK
assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined]
assert config.max_read_bytes == 32000 # type: ignore[attr-defined]
) # ReadConfig default, not BaseToolConfig.ASK
assert config.sensitive_patterns == ["**/*.key"] # type: ignore[attr-defined]
class TestToolManagerFiltering:
@ -98,10 +97,10 @@ class TestToolManagerFiltering:
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert len(tools) < len(manager._available)
assert len(tools) < len(manager._all_tools)
assert "bash" in tools
assert "grep" in tools
assert "read_file" not in tools
assert "read" not in tools
assert "write_file" not in tools
def test_disabled_tools_excludes_disabled(self):
@ -113,11 +112,11 @@ class TestToolManagerFiltering:
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert len(tools) < len(manager._available)
assert len(tools) < len(manager._all_tools)
assert "bash" not in tools
assert "write_file" not in tools
assert "grep" in tools
assert "read_file" in tools
assert "read" in tools
def test_enabled_tools_takes_precedence_over_disabled(self):
vibe_config = build_test_vibe_config(
@ -136,12 +135,11 @@ class TestToolManagerFiltering:
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["*_file"], # Matches read_file, write_file
disabled_tools=["write_*"], # Matches write_file
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert "read_file" not in tools
assert "write_file" not in tools
assert "bash" in tools
assert "grep" in tools
@ -159,6 +157,18 @@ class TestToolManagerFiltering:
assert "bash" in tools
assert "grep" in tools
def test_get_raises_for_disabled_tool(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["bash"],
)
manager = ToolManager(lambda: vibe_config)
assert "bash" not in manager.available_tools
with pytest.raises(NoSuchToolError):
manager.get("bash")
def test_case_insensitive_matching(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
@ -180,7 +190,7 @@ class TestToolManagerFiltering:
tools = manager.available_tools
assert "bash" in tools
assert "grep" in tools
assert "read_file" in tools
assert "read" in tools
def test_tool_paths_with_file_and_directory(self, tmp_path: Path):
"""Should handle a mix of file and directory paths in tool_paths."""

View file

@ -11,12 +11,7 @@ from vibe.core.tools.builtins.bash import (
BashToolConfig,
_collect_outside_dirs,
)
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileArgs,
ReadFileState,
ReadFileToolConfig,
)
from vibe.core.tools.builtins.read import Read, ReadArgs, ReadConfig, ReadState
from vibe.core.tools.builtins.write_file import (
WriteFile,
WriteFileArgs,
@ -41,13 +36,11 @@ class TestFileToolScratchpadPermissions:
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_read_file_scratchpad_always_allowed(self):
def test_read_scratchpad_always_allowed(self):
sp = scratchpad_mod.get_scratchpad_dir("test-session")
assert sp is not None
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), state=ReadFileState()
)
result = tool.resolve_permission(ReadFileArgs(path=str(sp / "notes.txt")))
tool = Read(config_getter=lambda: ReadConfig(), state=ReadState())
result = tool.resolve_permission(ReadArgs(file_path=str(sp / "notes.txt")))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS

View file

@ -85,7 +85,7 @@ async def test_ui_shows_success_in_case_of_zero_code(vibe_app: VibeApp) -> None:
async def test_ui_shows_failure_in_case_of_non_zero_code(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!bash -lc 'exit 7'"
chat_input.value = "!bash -c 'exit 7'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
@ -127,7 +127,7 @@ async def test_ui_handles_utf8_output(vibe_app: VibeApp) -> None:
async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!bash -lc \"printf '\\\\xff\\\\xfe' 1>&2\""
chat_input.value = "!bash -c \"printf '\\\\xff\\\\xfe' 1>&2\""
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
@ -198,7 +198,7 @@ async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
# print lines with a small delay so streaming has a chance to show partial output
chat_input.value = "!bash -lc 'echo first; echo second'"
chat_input.value = "!bash -c 'echo first; echo second'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)