DEV Community

Alexis
Alexis

Posted on

Auto-Approve WebFetch and WebSearch in Claude Code with Hooks

TL;DR: Claude Code's standard permission settings for WebFetch and WebSearch have known bugs. Hooks bypass these issues and work in CLI, VSCode, and sub-agents.


If you use Claude Code regularly, you've likely encountered the permission prompt fatigue. Every web search, every URL fetch: "Allow WebSearch?" "Allow WebFetch?" Click. Click. Click. You add these tools to your settings.local.json allow list, and yet the prompts keep appearing.

It's a collection of known bugs and edge cases in Claude Code's permission system that affect WebFetch and WebSearch specifically.

The Problem with Standard Permission Settings

The natural approach is to add WebFetch and WebSearch to your permission configuration:

{
  "permissions": {
    "allow": [
      "WebFetch",
      "WebSearch"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This should work. Sometimes it does. Often it doesn't.

1. Wildcard Patterns Don't Match

Adding WebFetch(domain:*) to allow all domains doesn't actually work. Claude Code still prompts for each new domain, then adds specific entries like WebFetch(domain:github.com) to your settings file. The wildcard sits there, ignored.

2. VSCode Extension Ignores Settings

The VSCode extension requires two settings to work together, and the documentation doesn't make this clear:

{
  "claudeCode.allowDangerouslySkipPermissions": true,
  "claudeCode.initialPermissionMode": "bypassPermissions"
}
Enter fullscreen mode Exit fullscreen mode

Without both, your project-level settings are ignored.

3. Sub-Agents Get Auto-Denied

Planning mode and sub-agents (like Explore) run in "dontAsk" mode, which auto-denies tools even when you've configured bypass permissions. You see errors like:

Error: Permission to use WebSearch has been auto-denied in dontAsk mode.
Enter fullscreen mode Exit fullscreen mode

4. Path Normalization Issues

On macOS and Linux, path expansion inconsistencies cause permission rules to not match. A rule for /home/username/project/** won't match a request for ~/project/file.txt.

The Solution: Use Hooks

Hooks bypass the permission system entirely. They intercept tool calls and return explicit allow/deny decisions before the permission check occurs. This works reliably because:

  1. Hooks run before the permission system evaluates rules
  2. Hooks work in all contexts: CLI, VSCode, sub-agents, planning mode
  3. Hooks use explicit JSON responses, not pattern matching

Two hooks handle different scenarios:

  • PreToolUse: Intercepts tool calls before execution
  • PermissionRequest: Intercepts permission dialog requests

Configuration

Step 1: Create the Hook Script

Create .claude/hooks/auto_allow_web_tools.py in your project:

#!/usr/bin/env python3
"""
Claude Code hook to auto-approve WebFetch and WebSearch tools.

Exit codes:
  0 = Hook made a decision (JSON output printed to stdout)
  1 = Hook did not make a decision (fall back to normal permission flow)

This hook is FAIL-SAFE: any unexpected error exits with code 1,
which causes Claude Code to fall back to its normal permission prompt.
"""
import json
import sys
from urllib.parse import urlparse


# Optional: restrict WebFetch to a domain allowlist.
# Leave as None to allow ALL domains.
ALLOWED_FETCH_DOMAINS = None  # e.g. {"learn.microsoft.com", "github.com"}

# Optional: force WebSearch to only return results from certain domains.
# Leave as None to not force domain filtering.
FORCE_SEARCH_ALLOWED_DOMAINS = None  # e.g. ["learn.microsoft.com", "github.com"]


def log_error(msg: str) -> None:
    """Log to stderr for debugging (won't affect hook output)."""
    print(f"[auto_allow_web_tools] {msg}", file=sys.stderr)


def host_of(url: str) -> str:
    """Extract lowercase hostname from URL, or empty string on failure."""
    try:
        return (urlparse(url).hostname or "").lower()
    except Exception:
        return ""


def output_and_exit(result: dict) -> None:
    """
    Print JSON result to stdout and exit with code 0.
    Validates output before printing to prevent malformed JSON issues.
    """
    try:
        output = json.dumps(result)
        print(output)
        sys.exit(0)
    except (TypeError, ValueError) as e:
        log_error(f"Failed to serialize output: {e}")
        sys.exit(1)


def handle_pre_tool_use(tool: str, tool_input: dict) -> None:
    """Handle PreToolUse event - decide whether to allow/deny/modify the tool call."""
    updated_input = None

    # Check domain allowlist for WebFetch
    if tool == "WebFetch" and ALLOWED_FETCH_DOMAINS is not None:
        url = tool_input.get("url", "")
        host = host_of(url)
        if host not in ALLOWED_FETCH_DOMAINS:
            output_and_exit({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": f"WebFetch blocked by hook: {host} not in allowlist",
                }
            })
            return  # Never reached, but makes flow clear

    # Force domain filtering for WebSearch
    if tool == "WebSearch" and FORCE_SEARCH_ALLOWED_DOMAINS is not None:
        updated_input = dict(tool_input)
        updated_input["allowed_domains"] = list(FORCE_SEARCH_ALLOWED_DOMAINS)

    # Allow the tool call
    result = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": "Auto-approved WebSearch/WebFetch via hook",
        }
    }
    if updated_input is not None:
        result["hookSpecificOutput"]["updatedInput"] = updated_input

    output_and_exit(result)


def handle_permission_request() -> None:
    """Handle PermissionRequest event - auto-allow if UI shows permission dialog."""
    output_and_exit({
        "hookSpecificOutput": {
            "hookEventName": "PermissionRequest",
            "decision": {"behavior": "allow"},
        }
    })


def main() -> None:
    """
    Main entry point. Reads hook input from stdin and dispatches to handlers.

    IMPORTANT: This hook ONLY handles WebFetch and WebSearch tools.
    All other tools exit with code 1 (no decision), falling back to normal permissions.
    """
    try:
        # Parse input from Claude Code
        data = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        log_error(f"Invalid JSON input: {e}")
        sys.exit(1)
    except Exception as e:
        log_error(f"Failed to read stdin: {e}")
        sys.exit(1)

    event = data.get("hook_event_name", "")
    tool = data.get("tool_name", "")
    tool_input = data.get("tool_input") or {}

    # SECURITY: Only handle WebFetch and WebSearch - nothing else
    if tool not in ("WebFetch", "WebSearch"):
        sys.exit(1)

    # Dispatch to appropriate handler
    if event == "PreToolUse":
        handle_pre_tool_use(tool, tool_input)
    elif event == "PermissionRequest":
        handle_permission_request()
    else:
        # Unknown event type - don't make a decision
        log_error(f"Unknown event type: {event}")
        sys.exit(1)


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        # Catch-all: ensure we NEVER exit 0 without valid output
        log_error(f"Unexpected error: {e}")
        sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure the Hook

Add the hook configuration to .claude/settings.local.json:

{
  "permissions": {
    "allow": [],
    "deny": [],
    "ask": []
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "WebFetch|WebSearch",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/auto_allow_web_tools.py",
            "timeout": 5
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "WebFetch|WebSearch",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/auto_allow_web_tools.py",
            "timeout": 5
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Windows users: Replace python3 with py -3 or the full path to your Python executable.

How It Works

PreToolUse Hook

When Claude decides to call WebFetch or WebSearch:

  1. Claude Code invokes the hook with tool details on stdin
  2. The hook checks if the tool is WebFetch or WebSearch
  3. If yes, it outputs JSON with "permissionDecision": "allow"
  4. The tool executes without prompting

PermissionRequest Hook

If a permission dialog would appear (edge cases, sub-agents):

  1. Claude Code invokes the hook before showing the dialog
  2. The hook outputs JSON with "decision": {"behavior": "allow"}
  3. The dialog is suppressed, permission granted

Fail-Safe Design

The script exits with code 1 for any unexpected condition:

  • Non-WebFetch/WebSearch tools fall through to normal permissions
  • JSON parse errors fall through
  • Any exception falls through

Optional: Domain Restrictions

The script supports optional domain filtering. Uncomment and configure:

# Only allow fetching from these domains
ALLOWED_FETCH_DOMAINS = {"docs.microsoft.com", "github.com", "stackoverflow.com"}

# Force all web searches to only return results from these domains
FORCE_SEARCH_ALLOWED_DOMAINS = ["docs.microsoft.com", "github.com"]
Enter fullscreen mode Exit fullscreen mode

With ALLOWED_FETCH_DOMAINS set, WebFetch requests to unlisted domains are denied with a clear message.

With FORCE_SEARCH_ALLOWED_DOMAINS set, the hook modifies the WebSearch input to include domain filtering, regardless of what Claude requested.

Project vs Global Configuration

You can place this configuration in different locations:

Location Scope Git
.claude/settings.local.json This project only Should be in .gitignore
.claude/settings.json This project, shareable Can be committed
~/.claude/settings.json All projects N/A

For team projects, commit .claude/hooks/auto_allow_web_tools.py but keep settings.local.json in .gitignore so each developer can configure their own preferences.

Debugging

If the hook isn't working:

  1. Check the script runs: echo '{"hook_event_name":"PreToolUse","tool_name":"WebFetch","tool_input":{"url":"https://example.com"}}' | python3 .claude/hooks/auto_allow_web_tools.py

  2. Check stderr: Errors are logged to stderr, visible in Claude Code's debug output

  3. Verify the matcher: The regex WebFetch|WebSearch must match exactly

  4. Check Python path: Ensure python3 (or py -3 on Windows) is available in PATH

Summary

Approach Works in CLI Works in VSCode Works in Sub-Agents
permissions.allow Sometimes Requires extra settings No
Wildcard patterns No No No
Hooks (this solution) Yes Yes Yes

Hooks work where permission settings don't. The exit-code-1 fallback means you're never locked out if something goes wrong.

Learn more:


Have questions or improvements? Open an issue or PR on your project's repository.

Top comments (0)