Skip to content

Pre Tool Use Hooks Don't Fire for BYOM Anthropic Models #893

@susheels

Description

@susheels

Bug: preToolUse hooks not invoked for Anthropic BYOM sessions

Summary

The Copilot CLI does not invoke preToolUse hooks when a session uses an Anthropic BYOM provider (provider.type = "anthropic" with a custom base_url). Hooks work correctly for:

  • OpenAI BYOM sessions (custom base_url)
  • Both OpenAI and Anthropic sessions via the GitHub Copilot API (no custom provider)

Environment

  • github-copilot-sdk (Python): 0.1.32
  • Copilot CLI binary (copilot/bin/VERSION): 1.0.2
  • Python: 3.12

Reproduction

Self-contained script with a mock LLM server — no external services needed. The mock returns a single view tool call in the appropriate format (OpenAI or Anthropic). The hook denies all tool calls.

import asyncio, json, threading, uuid
from http.server import HTTPServer, BaseHTTPRequestHandler
from copilot import CopilotClient, PermissionHandler

PORT = 18901

class MockLLM(BaseHTTPRequestHandler):
    """Returns a view(/app) tool call once, then plain text."""
    _first = True

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        is_first = MockLLM._first
        MockLLM._first = False

        if "chat/completions" in self.path:
            if is_first:
                resp = {"id": "c-1", "object": "chat.completion",
                        "choices": [{"index": 0, "finish_reason": "tool_calls", "message": {
                            "role": "assistant", "content": None,
                            "tool_calls": [{"id": "call_1", "type": "function",
                                            "function": {"name": "view", "arguments": '{"path":"/app"}'}}]}}],
                        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}
            else:
                resp = {"id": "c-2", "object": "chat.completion",
                        "choices": [{"index": 0, "finish_reason": "stop",
                                     "message": {"role": "assistant", "content": "Done."}}],
                        "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}
        else:
            if is_first:
                resp = {"id": "m-1", "type": "message", "role": "assistant",
                        "content": [{"type": "tool_use", "id": "toolu_1", "name": "view",
                                     "input": {"path": "/app"}}],
                        "model": "mock", "stop_reason": "tool_use",
                        "usage": {"input_tokens": 1, "output_tokens": 1}}
            else:
                resp = {"id": "m-2", "type": "message", "role": "assistant",
                        "content": [{"type": "text", "text": "Done."}],
                        "model": "mock", "stop_reason": "end_turn",
                        "usage": {"input_tokens": 1, "output_tokens": 1}}

        body = json.dumps(resp).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *_): pass

async def test(client, label, provider_cfg):
    hook_fired = []
    MockLLM._first = True

    def hook(data, _inv=None):
        hook_fired.append(data.get("toolName"))
        return {"permissionDecision": "deny", "permissionDecisionReason": "blocked"}

    session = await client.create_session({
        "model": "test",
        "provider": provider_cfg,
        "hooks": {"on_pre_tool_use": hook},
        "on_permission_request": PermissionHandler.approve_all,
    })
    await session.send({"prompt": "view /app"})
    await asyncio.sleep(5)
    await session.disconnect()

    result = "PASS" if hook_fired else "FAIL"
    print(f"  {label:25s} {result}  (hook calls: {len(hook_fired)})")

async def main():
    server = HTTPServer(("127.0.0.1", PORT), MockLLM)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    base = f"http://127.0.0.1:{PORT}"

    client = CopilotClient({"use_logged_in_user": False})
    await client.start()

    from importlib.metadata import version
    from pathlib import Path
    import copilot
    cli_ver = (Path(copilot.__path__[0]) / "bin" / "VERSION").read_text().strip()
    sdk_ver = version("github-copilot-sdk")
    print(f"Copilot CLI: {cli_ver} | SDK: {sdk_ver} | Mock: {base}\n")

    await test(client, "OpenAI BYOM", {"type": "openai", "base_url": f"{base}/v1", "api_key": "x"})
    await test(client, "Anthropic BYOM", {"type": "anthropic", "base_url": base, "api_key": "x"})

    await client.stop()
    server.shutdown()

if __name__ == "__main__":
    asyncio.run(main())

Output

Copilot CLI: 1.0.2 | SDK: 0.1.32 | Mock: http://127.0.0.1:18901

  OpenAI BYOM               PASS  (hook calls: 1)
  Anthropic BYOM            FAIL  (hook calls: 0)

Expected Behavior

preToolUse hook fires before tool execution for both providers. Both tests should show PASS.

Actual Behavior

  • OpenAI BYOM: CLI sends hooks.invoke JSON-RPC request → hook fires → tool denied.
  • Anthropic BYOM: CLI skips hooks.invoke entirely → tool executes without hook interception.

Metadata

Metadata

Assignees

No one assigned

    Labels

    mcsruntimeRequires a change in the copilot-agent-runtime reporuntime triageTriggers automated runtime triage workflow

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions