Files
wehub-resource-sync c6af9e284a
Tests / catch-all (windows-latest) (push) Has been cancelled
Tests / jvm (macos-latest) (push) Has been cancelled
Tests / jvm (ubuntu-latest) (push) Has been cancelled
Tests / jvm (windows-latest) (push) Has been cancelled
Tests / native (macos-latest) (push) Has been cancelled
Tests / native (ubuntu-latest) (push) Has been cancelled
Tests / native (windows-latest) (push) Has been cancelled
Tests / niche (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (macos-latest) (push) Has been cancelled
Tests / other-langs (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (windows-latest) (push) Has been cancelled
Tests / catch-all (macos-latest) (push) Has been cancelled
Tests / catch-all (ubuntu-latest) (push) Has been cancelled
Docs Build / build (push) Has been cancelled
Docs Build / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Build and Push Docker Images / build-and-push (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:38 +08:00

778 lines
40 KiB
Python

import json
import pickle
from datetime import datetime, timedelta
from io import StringIO
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from serena.hooks import (
HookClient,
PreToolUseAutoApproveSerenaHook,
PreToolUseRemindAboutSymbolicToolsHook,
SessionEndCleanupHook,
hook_commands,
)
ToolUseCounter = PreToolUseRemindAboutSymbolicToolsHook.ToolUseCounter
def _make_stdin(data: dict) -> StringIO:
return StringIO(json.dumps(data))
def _base_input(
tool_name: str = "grep_search",
session_id: str = "test-session-123",
tool_input: dict | None = None,
) -> dict:
return {
"session_id": session_id,
"tool_name": tool_name,
"tool_input": tool_input if tool_input is not None else {"query": "foo"},
}
def _read_input(tool_name: str = "read", session_id: str = "test-session-123", file_path: str = "src/foo.py") -> dict:
"""Build a hook payload for a read-style tool call against a file."""
return {
"session_id": session_id,
"tool_name": tool_name,
"tool_input": {"file_path": file_path},
}
class TestHookClientDetection:
"""Tests for the --client option propagation."""
def test_claude_code_client(self, tmp_path: Path):
stdin_data = _base_input()
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook._client == HookClient.CLAUDE_CODE
def test_vscode_client(self, tmp_path: Path):
stdin_data = _base_input()
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE)
assert hook._client == HookClient.VSCODE
class TestPreToolUseRemindAboutSerenaHook:
"""Tests for the PreToolUse hook that nudges the agent toward symbolic tools."""
def test_missing_tool_name_raises(self, tmp_path: Path):
stdin_data = {"session_id": "s1"}
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
with pytest.raises(ValueError, match="Tool name is required"):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
def test_missing_session_id_raises(self, tmp_path: Path):
stdin_data = {"tool_name": "grep"}
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
with pytest.raises(ValueError, match="Session ID is required"):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
def test_grep_tool_detection_claude_code(self, tmp_path: Path):
"""Claude Code uses the exact tool name ``Grep`` (lowercased to ``grep``)."""
for name, expected in [("grep", True), ("grep_search", False), ("mcp_grep", False), ("read", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (claude-code)"
def test_grep_tool_detection_non_claude_code(self, tmp_path: Path):
"""Non-Claude-Code clients fall back to substring matching to cover verbose tool names."""
for name, expected in [("grep_search", True), ("mcp_grep", True), ("read_file", False), ("serena_find", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (vscode)"
def test_grep_tool_detection_codex_shell_commands(self, tmp_path: Path):
"""Codex shell-command tools are classified by the command embedded in their payload."""
cases = [
("exec_command", {"cmd": "rg -n foo README.md"}, True),
("functions.shell_command", {"command": "rg -n foo README.md"}, True),
("functions.shell_command", {"command": "Get-Content README.md"}, False),
]
for tool_name, tool_input, expected in cases:
with (
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}"
def test_read_file_tool_detection_claude_code(self, tmp_path: Path):
"""Claude Code uses the exact tool name ``Read`` (lowercased to ``read``)."""
for name, expected in [("read", True), ("mcp__serena__read_file", True), ("grep", False), ("serena_search_for_pattern", False)]:
with (
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (claude-code)"
def test_read_file_tool_detection_non_claude_code(self, tmp_path: Path):
"""Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``)
combined with ``file``.
"""
cases = [
# canonical names
("read_file", True),
("readFile", True),
# alternative read verbs used by other agents/editors
("view_file", True),
("open_file", True),
("show_file", True),
# negatives: no "file", or "file" without a read verb, or modifying verbs
("grep_search", False),
("file_writer", False),
("write_file", False),
("edit_file", False),
]
for name, expected in cases:
with (
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE)
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (vscode)"
def test_read_non_code_file_counts_as_file_read(self, tmp_path: Path):
"""A ``Read`` call against a non-source file still counts as a file read."""
payload = _read_input(tool_name="read", file_path="notes/todo.txt")
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook.is_read_call() is True
assert hook.is_read_file_call() is True
assert hook.is_read_code_file_call() is False
def test_read_code_file_detection(self, tmp_path: Path):
"""Only source-like read targets count for the code-read reminder."""
cases = [
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="README.md"), False),
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="src/foo.py"), True),
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"}), False),
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"}), True),
]
for client, payload, expected in cases:
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(client)
assert hook.is_read_code_file_call() == expected, f"is_read_code_file_call() wrong for {client} / {payload}"
def test_read_file_tool_detection_codex_shell_commands(self, tmp_path: Path):
"""Codex shell-command tools count PowerShell file reads, including non-code files."""
cases = [
("exec_command", {"cmd": "cat README.md"}, True),
("functions.shell_command", {"command": "Get-Content README.md"}, True),
("functions.shell_command", {"command": "rg -n foo README.md"}, False),
]
for tool_name, tool_input, expected in cases:
with (
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX)
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {tool_name} / {tool_input}"
def test_serena_tool_detection(self, tmp_path: Path):
for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook.is_serena_symbolic_tool() == expected, f"is_serena_tool() wrong for {name}"
def test_no_output_below_threshold(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Below the threshold, the hook should produce no output (tool is allowed)."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_deny_output_after_threshold_greps_claude_code(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the grep threshold, the hook should output a deny."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "grep" in hook_output["additionalContext"].lower()
def test_deny_output_after_threshold_greps_vscode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the grep threshold, the hook should output a deny for VS Code."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep_search"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "grep" in hook_output["additionalContext"].lower()
def test_deny_output_after_threshold_greps_codex_shell_command(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the grep threshold, Codex ``functions.shell_command`` emits a deny."""
payload = _base_input("functions.shell_command", tool_input={"command": "rg -n foo README.md"})
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "additionalContext" not in hook_output
assert "grep" in hook_output["permissionDecisionReason"].lower()
def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the read file threshold, the hook should output a deny.
``_read_input`` supplies the direct ``file_path`` field emitted by file-read tools.
"""
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_read_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "read" in hook_output["additionalContext"].lower()
assert "files" in hook_output["additionalContext"].lower()
def test_no_deny_after_threshold_get_content_markdown_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""PowerShell ``Get-Content`` calls on markdown files do not count for the code-read reminder."""
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"})
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute()
assert capsys.readouterr().out == ""
def test_deny_output_after_threshold_get_content_code_file_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""PowerShell ``Get-Content`` calls on source files count for the code-read reminder."""
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"})
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX).execute()
result = json.loads(capsys.readouterr().out.strip())
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "additionalContext" not in hook_output
assert "read" in hook_output["permissionDecisionReason"].lower()
def test_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Using a Serena tool should reset counters, so the threshold is not reached."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
with patch("sys.stdin", _make_stdin(_base_input("mcp_serena_find_symbol"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_counter_resets_after_deny(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After a deny is emitted, the counter is reset so the next burst starts fresh."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
capsys.readouterr()
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_rate_limit_gates_entire_hook_within_interval(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""While within the rate-limit window, the entire hook must be a no-op:
no deny is emitted, AND the persisted counters must remain untouched.
"""
# first burst: should emit a deny
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
first_output = capsys.readouterr().out.strip()
assert first_output, "first burst should have emitted a deny"
# snapshot the persisted counter immediately after the deny was emitted
stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook)
stub_for_path.session_persistence_dir = str(tmp_path / "hook_data" / _base_input()["session_id"])
counter_before = ToolUseCounter.load(stub_for_path)
# second burst immediately after: within the rate-limit window, the entire
# hook must short-circuit — no deny output and no counter mutation
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
counter_after = ToolUseCounter.load(stub_for_path)
assert counter_after == counter_before, "gated hook must not mutate the persisted counter"
def test_rate_limit_allows_deny_after_interval(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Once the minimum deny interval has elapsed, a fresh burst emits a deny again."""
# first burst: emits a deny
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
capsys.readouterr()
# backdate the persisted last_deny_timestamp so the rate-limit window has expired
stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook)
stub_for_path.session_persistence_dir = str(tmp_path / "hook_data" / _base_input()["session_id"])
counter = ToolUseCounter.load(stub_for_path)
assert counter.last_deny_timestamp is not None
counter.last_deny_timestamp -= timedelta(seconds=ToolUseCounter._MIN_DENY_INTERVAL_SECONDS + 1)
counter.save(stub_for_path)
# second burst should now emit a deny again
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(_base_input("grep"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
second_output = capsys.readouterr().out.strip()
assert second_output, "after the rate-limit window elapsed, a new burst should emit a deny"
result = json.loads(second_output)
assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_non_symbolic_deny_emitted_when_combined_threshold_tripped(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Pre-populated state trips only the combined non-symbolic counter (not per-tool ones)
so execute() must fall through to the _build_non_symbolic_deny branch.
"""
# pre-populate the pickle so only the combined counter is over threshold
session_dir = tmp_path / "hook_data" / _base_input()["session_id"]
session_dir.mkdir(parents=True)
counter = ToolUseCounter(
n_recent_grep_uses=ToolUseCounter._GREP_USES_THRESHOLD - 1,
n_recent_read_file_uses=ToolUseCounter._READ_FILE_USES_THRESHOLD - 1,
n_recent_non_symbolic_uses=ToolUseCounter._NON_SYMBOLIC_USES_THRESHOLD,
last_grep_use_timestamp=datetime.now(),
last_read_file_use_timestamp=datetime.now(),
last_non_symbolic_use_timestamp=datetime.now(),
)
stub_for_path = object.__new__(PreToolUseRemindAboutSymbolicToolsHook)
stub_for_path.session_persistence_dir = str(session_dir)
counter.save(stub_for_path)
# invoke execute with a neutral (non-grep, non-read, non-serena) tool so that
# update() leaves the counters untouched and the fall-through branch is taken
with patch("sys.stdin", _make_stdin(_base_input("Edit"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
assert output, "expected a non-symbolic deny to be emitted"
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["permissionDecision"] == "deny"
assert "symbolic" in hook_output["additionalContext"].lower()
class TestToolUseCounter:
"""Tests for the time-windowed tool-use counter logic."""
def test_update_increments_grep_within_period(self):
counter = ToolUseCounter()
now = datetime.now()
counter.last_grep_use_timestamp = now - timedelta(seconds=1)
counter.n_recent_grep_uses = 1
hook = self._make_hook_stub("grep_search", now)
counter.update(hook)
assert counter.n_recent_grep_uses == 2
assert counter.last_grep_use_timestamp == now
def test_update_resets_grep_outside_period(self):
counter = ToolUseCounter()
now = datetime.now()
counter.last_grep_use_timestamp = now - timedelta(seconds=ToolUseCounter._GREP_RESET_PERIOD_SECONDS + 1)
counter.n_recent_grep_uses = 2
hook = self._make_hook_stub("grep_search", now)
counter.update(hook)
assert counter.n_recent_grep_uses == 1
assert counter.last_grep_use_timestamp == now
def test_update_increments_read_file_within_period(self):
counter = ToolUseCounter()
now = datetime.now()
counter.last_read_file_use_timestamp = now - timedelta(seconds=1)
counter.n_recent_read_file_uses = 1
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
counter.update(hook)
assert counter.n_recent_read_file_uses == 2
def test_update_resets_read_file_outside_period(self):
counter = ToolUseCounter()
now = datetime.now()
counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._READ_FILE_RESET_PERIOD_SECONDS + 1)
counter.n_recent_read_file_uses = 2
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
counter.update(hook)
assert counter.n_recent_read_file_uses == 1
def test_update_ignores_read_of_non_code_file(self):
"""A read call whose payload points at a non-source file does not increment the code-read counter."""
counter = ToolUseCounter()
now = datetime.now()
hook = self._make_hook_stub("read_file", now, file_path="notes/todo.txt")
counter.update(hook)
assert counter.n_recent_read_file_uses == 0
assert counter.last_read_file_use_timestamp is None
def test_serena_tool_resets_all_counters(self):
counter = ToolUseCounter(
n_recent_grep_uses=2,
n_recent_read_file_uses=2,
last_grep_use_timestamp=datetime.now(),
last_read_file_use_timestamp=datetime.now(),
)
hook = self._make_hook_stub("mcp_serena_overview", datetime.now())
counter.update(hook)
assert counter.n_recent_grep_uses == 0
assert counter.n_recent_read_file_uses == 0
assert counter.last_grep_use_timestamp is None
assert counter.last_read_file_use_timestamp is None
def test_non_matching_tool_leaves_counters_unchanged(self):
counter = ToolUseCounter(n_recent_grep_uses=1, n_recent_read_file_uses=1)
hook = self._make_hook_stub("write_file", datetime.now())
counter.update(hook)
assert counter.n_recent_grep_uses == 1
assert counter.n_recent_read_file_uses == 1
def test_persistence_round_trip(self, tmp_path: Path):
counter = ToolUseCounter(n_recent_grep_uses=2, n_recent_read_file_uses=1)
hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})()
counter.save(hook_stub)
loaded = ToolUseCounter.load(hook_stub)
assert loaded.n_recent_grep_uses == 2
assert loaded.n_recent_read_file_uses == 1
def test_load_returns_fresh_counter_on_missing_file(self, tmp_path: Path):
hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path / "nonexistent")})()
loaded = ToolUseCounter.load(hook_stub)
assert loaded == ToolUseCounter()
def test_load_returns_fresh_counter_on_corrupt_file(self, tmp_path: Path):
hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})()
path = tmp_path / ToolUseCounter._FILE_NAME
path.write_bytes(b"not a pickle")
loaded = ToolUseCounter.load(hook_stub)
assert loaded == ToolUseCounter()
def test_is_hook_active_respects_min_interval(self):
""":meth:`is_hook_active` returns False within the minimum interval, True outside it."""
counter = ToolUseCounter()
base = datetime.now()
# no prior deny → hook is always active
assert counter.is_hook_active(base)
# within the interval → hook gated
counter.last_deny_timestamp = base
interval = ToolUseCounter._MIN_DENY_INTERVAL_SECONDS
assert not counter.is_hook_active(base + timedelta(seconds=interval - 1))
assert not counter.is_hook_active(base)
# at/after the interval → hook active again
assert counter.is_hook_active(base + timedelta(seconds=interval))
assert counter.is_hook_active(base + timedelta(seconds=interval + 1))
def test_reset_preserves_last_deny_timestamp(self):
"""``reset`` clears burst counters but must keep ``last_deny_timestamp`` intact."""
counter = ToolUseCounter()
base = datetime.now()
counter.last_deny_timestamp = base
counter.n_recent_grep_uses = 5
counter.n_recent_read_file_uses = 4
counter.n_recent_non_symbolic_uses = 7
counter.reset()
assert counter.n_recent_grep_uses == 0
assert counter.n_recent_read_file_uses == 0
assert counter.n_recent_non_symbolic_uses == 0
assert counter.last_deny_timestamp == base
@staticmethod
def _make_hook_stub(
tool_name: str,
timestamp: datetime,
file_path: str | None = None,
command_args_str: str | None = None,
) -> PreToolUseRemindAboutSymbolicToolsHook:
"""Create a minimal stub that satisfies ToolUseCounter.update without reading stdin.
Uses ``HookClient.VSCODE`` so that ``is_grep_tool`` / ``is_read_file_tool`` apply the
substring-matching branch — the counter tests below feed verbose tool names like
``grep_search`` / ``read_file`` which are only recognized under the non-Claude-Code
branch (Claude Code uses exact names ``grep`` / ``read``).
:param file_path: optional payload path; the hook classifies read-style tools as
file reads regardless of the path extension.
:param command_args_str: optional shell-argument tail; equivalent to ``file_path`` but
populates the ``cmd``-derived field instead of ``file_path``.
"""
stub = object.__new__(PreToolUseRemindAboutSymbolicToolsHook)
stub._tool_name = tool_name.lower()
stub._client = HookClient.VSCODE
stub.triggered_at_timestamp = timestamp
stub._command = None
stub._command_name = None
stub._command_args_str = command_args_str
stub._file_path = file_path
return stub
class TestPreToolUseAutoApproveSerenaHook:
"""Tests for the auto-approve hook that allows Serena tools while the client is in a permissive permission mode (``acceptEdits`` or ``auto``)."""
@staticmethod
def _approve_input(
tool_name: str = "mcp__serena__find_symbol",
permission_mode: str | None = "acceptEdits",
session_id: str = "auto-approve-session",
permission_mode_key: str = "permission_mode",
) -> dict:
data: dict = {
"session_id": session_id,
"tool_name": tool_name,
"tool_input": {},
}
if permission_mode is not None:
data[permission_mode_key] = permission_mode
return data
def test_approves_serena_tool_in_accept_edits_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""When the tool is a Serena tool and the mode is ``acceptEdits``, an allow decision is emitted."""
stdin_data = self._approve_input()
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "allow"
assert "acceptedits" in hook_output["permissionDecisionReason"].lower()
def test_approves_serena_tool_in_auto_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""When the tool is a Serena tool and the mode is ``auto``, an allow decision is emitted.
Claude Code's ``auto`` mode is the hands-off-execution mode users adopt for autonomous
runs; Serena tool calls should be auto-approved there for the same reason as in
``acceptEdits`` (Serena's destructive tools would otherwise still prompt per call).
"""
stdin_data = self._approve_input(permission_mode="auto")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
hook_output = result["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "allow"
# the reason must identify the actual mode that triggered the approval, not just say
# "auto-approved" — the substring " auto " (with surrounding spaces) discriminates the
# mode name from the unrelated "Auto-approved" prefix.
assert " auto " in hook_output["permissionDecisionReason"]
def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""The hook also reads the ``permissionMode`` (camelCase) variant of the field."""
stdin_data = self._approve_input(permission_mode_key="permissionMode")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
output = capsys.readouterr().out.strip()
assert json.loads(output)["hookSpecificOutput"]["permissionDecision"] == "allow"
def test_stays_silent_for_non_serena_tool(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Non-Serena tools get no decision even in ``acceptEdits`` mode (the hook stays silent)."""
stdin_data = self._approve_input(tool_name="Grep")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_stays_silent_in_default_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Serena tools in ``default`` mode get no decision (the hook stays silent)."""
stdin_data = self._approve_input(permission_mode="default")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_stays_silent_in_plan_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Other permission modes (e.g. ``plan``) must not trigger an auto-approve."""
stdin_data = self._approve_input(permission_mode="plan")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_stays_silent_in_bypass_permissions_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""``bypassPermissions`` already approves everything upstream in Claude Code, so the
hook deliberately does not emit an explicit allow there — it stays silent.
This test pins that boundary: only ``acceptEdits`` and ``auto`` are active modes for the
hook; expanding it further requires a deliberate change here.
"""
stdin_data = self._approve_input(permission_mode="bypassPermissions")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_stays_silent_in_dont_ask_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""``dontAsk`` is the user's deny-by-default posture (auto-deny unless allow-listed);
the hook honors that choice and stays silent rather than overriding it.
"""
stdin_data = self._approve_input(permission_mode="dontAsk")
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring."""
stdin_data = self._approve_input(permission_mode=None)
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute()
assert capsys.readouterr().out == ""
class TestSessionEndCleanupHook:
def test_removes_session_dir(self, tmp_path: Path):
session_dir = tmp_path / "hook_data" / "cleanup-session"
session_dir.mkdir(parents=True)
# place a file inside to verify recursive removal
(session_dir / "tool_use_counter.pkl").write_bytes(pickle.dumps(ToolUseCounter()))
stdin_data = {"session_id": "cleanup-session"}
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
SessionEndCleanupHook(HookClient.CLAUDE_CODE).execute()
assert not session_dir.exists()
def test_cleanup_is_idempotent(self, tmp_path: Path):
"""Cleaning up a non-existent session directory should not raise."""
stdin_data = {"session_id": "nonexistent-session"}
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
SessionEndCleanupHook(HookClient.CLAUDE_CODE).execute()
class TestHookCli:
"""Tests for the Click CLI entry point (serena-hooks)."""
def test_cleanup_command(self, tmp_path: Path):
session_dir = tmp_path / "hook_data" / "cli-cleanup"
session_dir.mkdir(parents=True)
(session_dir / "somefile").write_text("data")
stdin_json = json.dumps({"session_id": "cli-cleanup"})
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["cleanup", "--client", "claude-code"], input=stdin_json)
assert result.exit_code == 0
assert not session_dir.exists()
def test_remind_command(self, tmp_path: Path):
"""Invoke the remind command enough times to trigger a deny."""
runner = CliRunner()
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
stdin_json = json.dumps({"session_id": "cli-remind", "tool_name": "grep", "tool_input": {}})
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["remind", "--client", "claude-code"], input=stdin_json)
assert result.exit_code == 0
output = json.loads(result.output)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_auto_approve_command(self, tmp_path: Path):
"""The ``auto-approve`` CLI command emits an allow for a Serena tool in acceptEdits mode."""
stdin_json = json.dumps(
{
"session_id": "cli-auto-approve",
"tool_name": "mcp__serena__find_symbol",
"tool_input": {},
"permission_mode": "acceptEdits",
}
)
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json)
assert result.exit_code == 0
output = json.loads(result.output)
assert output["hookSpecificOutput"]["permissionDecision"] == "allow"
def test_auto_approve_command_in_auto_mode(self, tmp_path: Path):
"""The ``auto-approve`` CLI command emits an allow for a Serena tool in ``auto`` mode."""
stdin_json = json.dumps(
{
"session_id": "cli-auto-approve-auto-mode",
"tool_name": "mcp__serena__find_symbol",
"tool_input": {},
"permission_mode": "auto",
}
)
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json)
assert result.exit_code == 0
output = json.loads(result.output)
assert output["hookSpecificOutput"]["permissionDecision"] == "allow"
def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path):
"""The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``."""
stdin_json = json.dumps(
{
"session_id": "cli-auto-approve-default",
"tool_name": "mcp__serena__find_symbol",
"tool_input": {},
"permission_mode": "default",
}
)
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json)
assert result.exit_code == 0
assert result.output == ""
def test_client_default_is_claude_code(self, tmp_path: Path):
"""When --client is omitted, it defaults to claude-code."""
stdin_json = json.dumps({"session_id": "cli-default"})
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["activate"], input=stdin_json)
assert result.exit_code == 0
def test_invalid_client_rejected(self, tmp_path: Path):
stdin_json = json.dumps({"session_id": "s1"})
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["activate", "--client", "invalid"], input=stdin_json)
assert result.exit_code != 0
def test_invalid_stdin_exits_nonzero(self, tmp_path: Path):
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["activate", "--client", "claude-code"], input="not json")
assert result.exit_code != 0