"""Unit coverage for the extracted SDK permission/pre-tool hooks (gate_hooks). These ran inside the agent loop's closure before and had no isolated tests; pin the contract now that they're a module: policy -> allow/deny, and the ToolSearch loop-breaker fire + reset.""" import pytest from unittest.mock import patch, AsyncMock from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny from backend.apps.agents.core.models import AgentSession from backend.apps.agents.manager.streaming.HookContext import HookContext from backend.apps.agents.manager.permissions import gate_hooks from backend.apps.agents.manager.permissions.ApprovalDecision import ApprovalDecision from backend.apps.agents.manager.prompt.prompt_context import TOOLSEARCH_LOOP_THRESHOLD def p_ctx() -> HookContext: session = AgentSession(name="t", model="sonnet", dashboard_id="d") return HookContext( session=session, session_id=session.id, prompt="hi", builtin_perms={}, policy_defaults={}, sessions={}, ) @pytest.mark.asyncio async def test_can_use_tool_always_allow_returns_allow(): ctx = p_ctx() with patch.object(gate_hooks.path_gate, "maybe_override_policy", return_value=("always_allow", None)): result = await gate_hooks.can_use_tool(ctx, "Read", {"file_path": "/x"}, None) assert isinstance(result, PermissionResultAllow) @pytest.mark.asyncio async def test_can_use_tool_deny_returns_deny(): ctx = p_ctx() with patch.object(gate_hooks.path_gate, "maybe_override_policy", return_value=("deny", None)): result = await gate_hooks.can_use_tool(ctx, "Bash", {"command": "rm -rf /"}, None) assert isinstance(result, PermissionResultDeny) @pytest.mark.asyncio async def test_can_use_tool_ask_routes_through_approval(): ctx = p_ctx() with patch.object(gate_hooks.path_gate, "maybe_override_policy", return_value=("ask", None)), \ patch.object(gate_hooks, "resolve_ask", new=AsyncMock(return_value=ApprovalDecision(behavior="allow"))): result = await gate_hooks.can_use_tool(ctx, "Write", {"file_path": "/x"}, None) assert isinstance(result, PermissionResultAllow) @pytest.mark.asyncio async def test_pre_tool_hook_toolsearch_loopbreaker_fires_at_threshold(): ctx = p_ctx() ctx.ts_loop_count = TOOLSEARCH_LOOP_THRESHOLD - 1 with patch.object(gate_hooks, "gated_mcp_server_names", return_value=["gmail"]), \ patch.object(gate_hooks, "toolsearch_loop_redirect", return_value="Stop calling ToolSearch; use MCPActivate"): out = await gate_hooks.pre_tool_hook(ctx, {"tool_name": "ToolSearch"}, "tu1", None) deny = out["hookSpecificOutput"] assert deny["permissionDecision"] == "deny" assert "MCPActivate" in deny["permissionDecisionReason"] @pytest.mark.asyncio async def test_pre_tool_hook_counter_resets_on_non_toolsearch(): ctx = p_ctx() ctx.ts_loop_count = 5 with patch.object(gate_hooks.path_gate, "maybe_override_policy", return_value=("always_allow", None)): out = await gate_hooks.pre_tool_hook(ctx, {"tool_name": "Read", "tool_input": {}}, "tu1", None) assert ctx.ts_loop_count == 0 assert out == {} assert "tu1" in ctx.tool_start_times # an allowed tool records its start time