hooks: extend auto-approve to Claude Code's auto permission mode (#1445)

* hooks: extend auto-approve to Claude Code's auto permission mode

Closes #1386

---------

Co-authored-by: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com>
This commit is contained in:
Sam CarsonandMichael Panchenko authored and GitHub committed 2026-05-04 11:27:09 +02:00
1 parent c5a27ed9ec
commit 16d1a30397
4 files changed
+93 -17

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Hooks:
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
* General:
- Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`.
Instead, they can define `added_modes` to add modes on top of base and default modes.
+3 -3
View File
@@ -218,9 +218,9 @@ The hooks will:
`grep` or `read_file` calls without using any Serena tools in between.
- **`activate`**: Prompt the agent to activate the project and read Serena's instructions at session start.
- **`cleanup`**: Clean up hook session data when the session ends.
- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in `acceptEdits` mode,
so blanket edit approvals cover Serena's destructive tools (e.g. `replace_symbol_body`,
`rename_symbol`) instead of prompting on every call.
- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in a permissive
permission mode (`acceptEdits` or `auto`), so blanket approvals cover Serena's destructive
tools (e.g. `replace_symbol_body`, `rename_symbol`) instead of prompting on every call.
For more details on Claude Code's hook system, see the
[Claude Code hooks documentation](https://code.claude.com/docs/en/hooks).
+26 -13
View File
@@ -359,29 +359,41 @@ class SessionEndCleanupHook(Hook):
class PreToolUseAutoApproveSerenaHook(PreToolUseHook):
"""Pre-tool-use hook that auto-approves Serena tool calls while the client is in accept-edits mode.
"""Pre-tool-use hook that auto-approves Serena tool calls while the client is in a permissive permission mode.
Claude Code's ``acceptEdits`` permission mode only applies to its built-in editing tools;
Serena's destructive tools (e.g. ``replace_symbol_body`` or ``rename_symbol``) would still
prompt the user on every call. This hook emits an ``allow`` decision for any Serena MCP
tool call whenever the client reports ``acceptEdits`` as the active permission mode, so
blanket edit approvals also cover Serena's tools. In all other situations it stays silent,
preserving the default approval flow.
Claude Code's permissive permission modes (``acceptEdits`` for blanket edit approval and
``auto`` for hands-off autonomous execution) only apply to its built-in editing tools or
its auto-mode classifier; Serena's destructive tools (e.g. ``replace_symbol_body`` or
``rename_symbol``) would still prompt the user on every call. This hook emits an ``allow``
decision for any Serena MCP tool call whenever the client reports one of these modes as
the active permission mode, so blanket approvals also cover Serena's tools. In all other
situations it stays silent, preserving the default approval flow.
``bypassPermissions`` and ``dontAsk`` are deliberately excluded. ``bypassPermissions``
already approves everything before the hook would matter, so silence here is harmless.
``dontAsk`` is the user's deliberate deny-by-default posture (auto-deny unless an explicit
allow rule matches); the hook honors that choice and stays silent rather than blanket
overriding it.
"""
_ACCEPT_EDITS_MODE = "acceptEdits"
#: permission modes for which this hook emits an ``allow`` decision. Frozen so the
#: set is immutable at the class level; matching is exact (case-sensitive) against
#: the canonical mode strings emitted by Claude Code's hook payload.
_AUTO_APPROVE_MODES: frozenset[str] = frozenset({"acceptEdits", "auto"})
def is_accept_edits_mode(self) -> bool:
return self._permission_mode == self._ACCEPT_EDITS_MODE
def is_auto_approve_mode(self) -> bool:
return self._permission_mode in self._AUTO_APPROVE_MODES
def execute(self) -> None:
# only emit a decision when both the tool and the mode match; stay silent otherwise
if not self.is_serena_tool() or not self.is_accept_edits_mode():
if not self.is_serena_tool() or not self.is_auto_approve_mode():
return
# name the actual mode in the reason so logs/debug output are unambiguous
# (the same hook handles multiple modes now)
output_data = self.OutputData(
permission_decision="allow",
permission_decision_reason="Auto-approved: Serena tool call while client is in acceptEdits mode.",
permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.",
)
click.echo(output_data.to_json_string())
@@ -426,7 +438,8 @@ class HookCommands(AutoRegisteringGroup):
@staticmethod
@click.command(
"auto-approve",
help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in acceptEdits mode (Claude Code).",
help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in a "
"permissive permission mode (acceptEdits or auto, Claude Code).",
)
@_client_option
def auto_approve(client: str) -> None:
+60 -1
View File
@@ -409,7 +409,7 @@ class TestToolUseCounter:
class TestPreToolUseAutoApproveSerenaHook:
"""Tests for the auto-approve hook that allows Serena tools while the client is in ``acceptEdits`` mode."""
"""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(
@@ -440,6 +440,27 @@ class TestPreToolUseAutoApproveSerenaHook:
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")
@@ -470,6 +491,27 @@ class TestPreToolUseAutoApproveSerenaHook:
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)
@@ -542,6 +584,23 @@ class TestHookCli:
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(