mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 18:16:43 +00:00
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:
1 parent
c5a27ed9ec
commit
16d1a30397
4 files changed
+93
-17
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user