diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d8f79c..abdc5f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 646873b1..cbfe0d4e 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -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). diff --git a/src/serena/hooks.py b/src/serena/hooks.py index a9322193..b1becc04 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -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: diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index 24af5523..bf7ca82c 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -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(