"""The SDK permission/pre-tool hooks, lifted out of the agent loop. can_use_tool answers the SDK's permission callback; pre_tool_hook runs before each tool call and also carries the two MCP loop-breakers (ToolSearch-thrash redirect + one-shot connect offer). Both operate on a HookContext passed by reference, so the shared counters survive across calls. The dict returns are the claude_agent_sdk hook protocol (hookSpecificOutput), not internal state.""" import asyncio import logging import os import time from typing import Dict, Optional, Union from typeguard import typechecked # Runtime aliases stay `object` so the 350ms claude_agent_sdk+mcp chain stays off the boot graph; the hook body re-imports the real classes at call time, when the SDK is already resident. from typing import TYPE_CHECKING if TYPE_CHECKING: from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny else: PermissionResultAllow = PermissionResultDeny = object from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings from backend.apps.agents.manager.permissions import path_gate from backend.apps.agents.manager.permissions.decision import effective_policy from backend.apps.agents.manager.prompt.tool_catalog import gated_mcp_server_names from backend.apps.agents.manager.prompt.prompt_context import ( TOOLSEARCH_LOOP_THRESHOLD, toolsearch_loop_redirect, ) from backend.apps.agents.manager.streaming.HookContext import HookContext from backend.apps.agents.manager.permissions.workflow_approval import ( is_claude_schedule_skill, note_tool_used, resolve_ask, ) logger = logging.getLogger(__name__) @typechecked async def can_use_tool( ctx: HookContext, tool_name: str, input_data: object, context: object ) -> Union[PermissionResultAllow, PermissionResultDeny]: from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny if is_claude_schedule_skill(tool_name, input_data): note_tool_used(ctx.session_id, tool_name, False) return PermissionResultDeny( message="Use the ScheduleWorkflow MCP tools instead of Claude's internal schedule skill." ) sensitive_pattern: Optional[str] = None if tool_name != "AskUserQuestion": policy, sensitive_pattern = path_gate.maybe_override_policy( effective_policy(tool_name, ctx.builtin_perms, ctx.policy_defaults), tool_name, input_data ) if policy == "always_allow": note_tool_used(ctx.session_id, tool_name, True) return PermissionResultAllow(updated_input=input_data) if policy == "deny": note_tool_used(ctx.session_id, tool_name, False) return PermissionResultDeny(message="Tool denied by permission policy") decision = await resolve_ask(ctx, tool_name, input_data, sensitive_pattern) if decision.behavior == "allow": return PermissionResultAllow( updated_input=decision.updated_input if decision.updated_input is not None else input_data ) return PermissionResultDeny( message=decision.message or "User denied this action" ) @typechecked async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optional[str], context: object) -> Dict[str, object]: tool_name = input_data.get("tool_name", "") hook_event = input_data.get("hook_event_name", "PreToolUse") # Read-only sessions (onboarding's unattended audit) block Edit/Bash/NotebookEdit at the tool-list # level, but Write survives so the audit can drop its ONE report file. Write also CLOBBERS an # existing path, though, so without this a read-only agent could overwrite ~/Downloads/taxes.pdf. # Deny Write when its target already exists: new report file yes, destroying an existing file no. if getattr(ctx.session, "read_only", False) and tool_name == "Write": p = (input_data.get("tool_input") or {}).get("file_path", "") if p and os.path.exists(os.path.expanduser(p)): note_tool_used(ctx.session_id, tool_name, False) return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": "This is a read-only audit: it may create a new report but must never overwrite an existing file. Choose a filename that does not exist yet.", } } # ToolSearch loop-breaker. Gated MCP servers are withheld from the SDK until MCPActivate, so the CLI's native ToolSearch can never find them; small models thrash (empty ToolSearch, retry) for minutes until the user pauses. Let the first couple through, then redirect to the gate. Any non-ToolSearch call is real progress, so the counter resets. Gated-server lookup is deferred behind the threshold so the common (non-looping) path stays free. if tool_name == "ToolSearch": ctx.ts_loop_count += 1 if ctx.ts_loop_count >= TOOLSEARCH_LOOP_THRESHOLD: gated = gated_mcp_server_names(ctx.session.allowed_tools, ctx.session.active_mcps) reason = toolsearch_loop_redirect(ctx.ts_loop_count, gated) if reason: logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {ctx.session_id} (n={ctx.ts_loop_count})") # 2B-MCP: also surface a one-click connect offer to the USER for the vetted gated servers the agent keeps reaching for. Suggest-only: this just shows a card on the same channel the preflight uses; activation still requires MCPActivate + the dispatch gate, so it opens no side channel. Once per run, fail-open (an offer hiccup must never block the agent). if not ctx.mcp_offer_sent: try: from backend.apps.agents.core.mcp_preflight import offer_for_gated_server settings = load_settings() offers = [o for o in (offer_for_gated_server(n, settings) for n in gated) if o] if offers: ctx.mcp_offer_sent = True await ws_manager.send_to_session(ctx.session_id, "agent:mcp_suggestions", { "session_id": ctx.session_id, "suggestions": offers, "is_vague": False, }) except Exception: logger.debug("mid-run MCP connect offer skipped", exc_info=True) return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": reason, } } else: ctx.ts_loop_count = 0 # MCPSearch is the agent saying "I need an integration I don't have" (e.g. "no email connected"). Don't make the user read a wall of options: fire the same curated connect card the launch preflight uses, keyed to their original request. Non-blocking (the search proceeds) and once per run; covers the common path the ToolSearch-loop branch misses because a capable model does one MCPSearch instead of thrashing. Suggest-only as ever. if (tool_name.endswith("MCPSearch") or tool_name.endswith("MCPList")) and not ctx.mcp_offer_sent: ctx.mcp_offer_sent = True async def offer_from_prompt(): try: from backend.apps.agents.core.mcp_preflight import run_preflight result = await run_preflight(ctx.prompt, task_id=ctx.session_id, require_vague=False) offers = result.get("suggestions", []) if offers: await ws_manager.send_to_session(ctx.session_id, "agent:mcp_suggestions", { "session_id": ctx.session_id, "suggestions": offers, "is_vague": False, }) except Exception: logger.debug("MCPSearch-triggered connect offer skipped", exc_info=True) asyncio.create_task(offer_from_prompt()) if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) if is_claude_schedule_skill(tool_name, tool_input): note_tool_used(ctx.session_id, tool_name, False) return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": "Use the ScheduleWorkflow MCP tools instead of Claude's internal schedule skill.", } } policy, sensitive_pattern = path_gate.maybe_override_policy( effective_policy(tool_name, ctx.builtin_perms, ctx.policy_defaults), tool_name, tool_input ) if policy == "always_allow": note_tool_used(ctx.session_id, tool_name, True) if policy == "deny": note_tool_used(ctx.session_id, tool_name, False) return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": "Tool denied by permission policy", } } if policy == "ask": decision = await resolve_ask(ctx, tool_name, tool_input, sensitive_pattern) if decision.behavior == "allow": if tool_use_id: ctx.tool_start_times[tool_use_id] = time.time() return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "allow", } } return { "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": decision.message or "User denied this action", } } if tool_use_id: ctx.tool_start_times[tool_use_id] = time.time() return {}